fix(historian): #691.3 strict-ceiling gateway: re-ask once at the named ceiling, never answer a truncated read
This commit is contained in:
+20
@@ -102,10 +102,30 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient
|
||||
LastReadRawMaxValues = maxValues;
|
||||
ReadRawCallCount++;
|
||||
RawCalls.Add((startUtc, endUtc, maxValues));
|
||||
if (MaxValuesCeiling is { } ceiling && maxValues > ceiling)
|
||||
{
|
||||
// The real gateway's refusal (HistorianReadService.ReadRaw), raised on the first MoveNext before any row, as
|
||||
// the client package surfaces it: a HistorianGatewayException wrapping the RpcException.
|
||||
var rpc = new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
|
||||
CeilingRejectionDetail ?? $"max_values exceeds the server ceiling of {ceiling}; read large ranges by time-slicing " +
|
||||
"(split the range and read the slices sequentially), or enable " +
|
||||
"Historian:ReadLimits:AllowStreamingBeyondCeiling to page through server-side."));
|
||||
return ToAsyncStream(Array.Empty<HistorianSample>(),
|
||||
new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct);
|
||||
}
|
||||
var items = RawSource is null ? RawSamples : RawSource(startUtc, endUtc, maxValues).ToList();
|
||||
return CountingStream(ToAsyncStream(items, ThrowOnRead, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A strict gateway (<c>Historian:ReadLimits:AllowStreamingBeyondCeiling=false</c>) with this
|
||||
/// <c>MaxValuesCeiling</c>: a ReadRaw asking for more is refused <c>InvalidArgument</c> (#691.3).
|
||||
/// </summary>
|
||||
public int? MaxValuesCeiling;
|
||||
|
||||
/// <summary>Overrides the refusal's detail text (an older/other gateway wording).</summary>
|
||||
public string? CeilingRejectionDetail;
|
||||
|
||||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
|
||||
string tag,
|
||||
DateTime startUtc,
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #691.3: a gateway with <c>Historian:ReadLimits:AllowStreamingBeyondCeiling=false</c> refuses any <c>ReadRaw</c> whose
|
||||
/// <c>max_values</c> exceeds its <c>MaxValuesCeiling</c> (before streaming, naming the ceiling). An uncapped raw read
|
||||
/// (<c>NumValuesPerNode = 0</c>, sent as <see cref="int.MaxValue"/>) used to be refused outright — even a 10-row window.
|
||||
/// The read is now re-issued once with the ceiling the refusal named, and a stream that delivered the whole ceiling
|
||||
/// without reaching the window end is never answered from (<c>BadTooManyOperations</c>). The fake is AVEVA-shaped:
|
||||
/// a boundary row at the query start, <c>[start, end]</c> inclusive, capped at <c>max</c>.
|
||||
/// </summary>
|
||||
public sealed class GatewayStrictCeilingTests
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static readonly DateTime T0 = new(2026, 10, 12, 19, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime Now = T0.AddDays(1);
|
||||
|
||||
private static DateTime At(int seconds) => T0.AddSeconds(seconds);
|
||||
|
||||
private static HistorianSample Row(DateTime t, double v, uint quality = 0) =>
|
||||
new() { Tag = "T", Timestamp = Timestamp.FromDateTime(t), NumericValue = v, OpcQuality = 192, Quality = quality };
|
||||
|
||||
/// <summary>One stored sample per second, value = seconds, at +0 … +(count − 1); strict ceiling when given.</summary>
|
||||
private static FakeHistorianGatewayClient Store(int count, int? ceiling)
|
||||
{
|
||||
var stored = Enumerable.Range(0, count).Select(i => (T: At(i), V: (double)i)).ToList();
|
||||
return new FakeHistorianGatewayClient
|
||||
{
|
||||
MaxValuesCeiling = ceiling,
|
||||
RawSource = (start, end, max) =>
|
||||
{
|
||||
var rows = new List<HistorianSample>();
|
||||
if (stored.All(s => s.T != start))
|
||||
{
|
||||
var prior = stored.LastOrDefault(s => s.T < start);
|
||||
rows.Add(prior.T == default
|
||||
? new HistorianSample { Tag = "T", Timestamp = Timestamp.FromDateTime(start), NumericValue = 0, OpcQuality = 0, Quality = 1 }
|
||||
: Row(start, prior.V, 133));
|
||||
}
|
||||
rows.AddRange(stored.Where(s => s.T >= start && s.T <= end).Select(s => Row(s.T, s.V)));
|
||||
return rows.Take(max);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake, int maxRawRows = 1_000_000) =>
|
||||
new(fake, NullLogger<GatewayHistorianDataSource>.Instance, maxRawRows, new FixedClock(Now));
|
||||
|
||||
private sealed class FixedClock(DateTime now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(now);
|
||||
}
|
||||
|
||||
// ---- raw, uncapped ---------------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task An_uncapped_read_of_a_small_window_on_a_strict_gateway_is_served_with_the_ceiling_as_budget()
|
||||
{
|
||||
var fake = Store(10, ceiling: 1000);
|
||||
var ds = Source(fake);
|
||||
var r = await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
|
||||
|
||||
r.Samples.Select(s => s.Value).ShouldBe(Enumerable.Range(0, 10).Select(i => (object?)(double)i));
|
||||
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([int.MaxValue, 1000]); // refused, then the ceiling it named
|
||||
fake.RawStreamsCompleted.ShouldBe(1);
|
||||
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_uncapped_read_on_a_default_gateway_still_sends_one_unbounded_request()
|
||||
{
|
||||
var fake = Store(10, ceiling: null);
|
||||
await Source(fake).ReadRawAsync("T", At(0), At(60), 0, Ct);
|
||||
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([int.MaxValue]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_uncapped_window_holding_more_than_the_ceiling_is_a_request_limit_never_a_truncated_answer()
|
||||
{
|
||||
var fake = Store(100, ceiling: 50);
|
||||
var ds = Source(fake);
|
||||
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => ds.ReadRawAsync("T", At(0), At(200), 0, Ct));
|
||||
fake.RawStreamsCompleted.ShouldBe(1); // drained, never abandoned
|
||||
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_ceiling_full_stream_that_reached_the_window_end_is_complete()
|
||||
{
|
||||
// 50 rows: the boundary row + +0 … +48; the window end is +48 (the gateway's end is inclusive), so the stream
|
||||
// provably ended at the window end — complete although it delivered the whole ceiling.
|
||||
var fake = Store(100, ceiling: 50);
|
||||
var r = await Source(fake).ReadRawAsync("T", At(0), At(48), 0, Ct);
|
||||
r.Samples.Count.ShouldBe(48); // [+0, +48): the end is exclusive
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_without_a_parseable_ceiling_retries_at_the_gateways_default_ceiling()
|
||||
{
|
||||
var fake = Store(10, ceiling: 1_000_000);
|
||||
fake.CeilingRejectionDetail = "max_values exceeds the server ceiling; read large ranges by time-slicing.";
|
||||
var r = await Source(fake).ReadRawAsync("T", At(0), At(60), 0, Ct);
|
||||
r.Samples.Count.ShouldBe(10);
|
||||
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([int.MaxValue, RawAggregateReader.GatewayDefaultMaxValuesCeiling]);
|
||||
}
|
||||
|
||||
// ---- raw, capped -----------------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_cap_whose_allowance_exceeds_the_ceiling_is_served_with_the_ceiling_as_budget()
|
||||
{
|
||||
// cap 995 + 16 = 1011 > 1000: refused, re-asked with 1000 — a small window is served in full.
|
||||
var fake = Store(10, ceiling: 1000);
|
||||
var r = await Source(fake).ReadRawAsync("T", At(0), At(60), 995, Ct);
|
||||
r.Samples.Count.ShouldBe(10);
|
||||
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([995 + RawAggregateReader.PreWindowAllowance, 1000]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cap_above_the_ceiling_over_a_dense_window_is_a_request_limit_not_a_short_page()
|
||||
{
|
||||
// A short page would end the client's paging early (silent truncation), so it must fail instead.
|
||||
var fake = Store(100, ceiling: 50);
|
||||
var ds = Source(fake);
|
||||
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => ds.ReadRawAsync("T", At(0), At(200), 80, Ct));
|
||||
fake.RawCalls.Count.ShouldBe(2); // the ceiling re-read is final: no third read at the same ceiling
|
||||
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cap_that_fits_the_ceiling_is_unaffected()
|
||||
{
|
||||
var fake = Store(100, ceiling: 50);
|
||||
var r = await Source(fake).ReadRawAsync("T", At(0), At(200), 10, Ct);
|
||||
r.Samples.Count.ShouldBe(10);
|
||||
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([10 + RawAggregateReader.PreWindowAllowance]);
|
||||
}
|
||||
|
||||
// ---- per-sample aggregates (#688 clamp vs a ceiling below 1,000,000) ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_per_sample_aggregate_on_a_gateway_with_a_lower_ceiling_is_served_when_the_window_fits()
|
||||
{
|
||||
var fake = Store(30, ceiling: 1000); // default cap 1,000,000 ⇒ budget 1,000,000 > 1000
|
||||
var s = (await Source(fake).ReadProcessedAsync("T", At(0), At(30), TimeSpan.FromSeconds(10),
|
||||
HistoryAggregateType.Count, Ct)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([10, 10, 10]);
|
||||
fake.RawCalls[0].MaxValues.ShouldBe(RawAggregateReader.GatewayDefaultMaxValuesCeiling);
|
||||
fake.RawCalls[1].MaxValues.ShouldBe(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_per_sample_aggregate_whose_window_fills_the_lower_ceiling_is_a_request_limit()
|
||||
{
|
||||
var fake = Store(100, ceiling: 50);
|
||||
var ds = Source(fake);
|
||||
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => ds.ReadProcessedAsync("T", At(0), At(100),
|
||||
TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct));
|
||||
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unbounded_per_sample_aggregate_on_a_strict_gateway_is_served_with_the_ceiling()
|
||||
{
|
||||
var fake = Store(30, ceiling: 1000);
|
||||
var s = (await Source(fake, maxRawRows: 0).ReadProcessedAsync("T", At(0), At(30), TimeSpan.FromSeconds(10),
|
||||
HistoryAggregateType.Count, Ct)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([10, 10, 10]);
|
||||
fake.RawCalls.Take(2).Select(c => c.MaxValues).ShouldBe([int.MaxValue, 1000]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unbounded_per_sample_aggregate_filling_the_ceiling_is_a_request_limit()
|
||||
{
|
||||
var fake = Store(100, ceiling: 50);
|
||||
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => Source(fake, maxRawRows: 0).ReadProcessedAsync("T",
|
||||
At(0), At(100), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user