Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayStrictCeilingTests.cs
T

291 lines
14 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 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]);
}
// ---- the learned ceiling (review: a refused attempt counts as an error in the gateway's own metrics) -----------------
private static int Refusals(FakeHistorianGatewayClient fake) =>
fake.RawCalls.Count(c => fake.MaxValuesCeiling is { } n && c.MaxValues > n);
[Fact]
public async Task The_ceiling_is_learned_once_per_data_source_so_later_reads_are_not_refused()
{
var fake = Store(10, ceiling: 1000);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
await ds.ReadRawAsync("T", At(0), At(60), 995, Ct);
await ds.ReadProcessedAsync("T", At(0), At(10), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct);
fake.RawCalls.Select(c => c.MaxValues).Take(4).ShouldBe([int.MaxValue, 1000, 1000, 1000]);
fake.RawCalls[4].MaxValues.ShouldBe(1000); // the per-sample read shares it (its own budget 1,000,000)
Refusals(fake).ShouldBe(1);
}
[Fact]
public async Task A_gateway_that_now_streams_is_re_probed_when_a_learned_ceiling_read_comes_back_full()
{
var fake = Store(100, ceiling: 50);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct); // learns 50
fake.MaxValuesCeiling = null; // the operator enabled AllowStreamingBeyondCeiling
var r = await ds.ReadRawAsync("T", At(0), At(200), 0, Ct);
r.Samples.Count.ShouldBe(100); // not BadTooManyOperations from a stale ceiling
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([50, int.MaxValue]);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
fake.RawCalls[^1].MaxValues.ShouldBe(int.MaxValue); // forgotten
}
[Fact]
public async Task A_raised_but_still_strict_ceiling_is_re_learned_from_the_re_probe()
{
var fake = Store(100, ceiling: 50);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
fake.MaxValuesCeiling = 500;
(await ds.ReadRawAsync("T", At(0), At(200), 0, Ct)).Samples.Count.ShouldBe(100);
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([50, int.MaxValue, 500]);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
fake.RawCalls[^1].MaxValues.ShouldBe(500);
}
[Fact]
public async Task A_genuinely_over_ceiling_read_costs_one_re_probe_and_no_repeat_at_the_same_ceiling()
{
var fake = Store(100, ceiling: 50);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(5), 0, Ct);
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => ds.ReadRawAsync("T", At(0), At(200), 0, Ct));
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([50, int.MaxValue]);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task A_lowered_ceiling_is_re_learned_from_the_refusal_of_the_learned_one()
{
var fake = Store(10, ceiling: 1000);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
fake.MaxValuesCeiling = 50;
(await ds.ReadRawAsync("T", At(0), At(60), 0, Ct)).Samples.Count.ShouldBe(10);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
fake.RawCalls.Skip(2).Select(c => c.MaxValues).ShouldBe([1000, 50, 50]);
}
[Fact]
public async Task Concurrent_reads_share_the_learned_ceiling_safely()
{
var fake = Store(10, ceiling: 1000);
var ds = Source(fake);
await ds.ReadRawAsync("T", At(0), At(60), 0, Ct);
var results = await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => Task.Run(() => ds.ReadRawAsync("T", At(0), At(60), 0, Ct))));
results.ShouldAllBe(r => r.Samples.Count == 10);
Refusals(fake).ShouldBe(1);
}
// ---- 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));
}
[Theory] // review: the message names the limit that actually bound the read — the gateway's ceiling, not the cap
[InlineData(0)] // unbounded cap: used to say "more than 0 raw samples (MaxTimeWeightedRawRows)"
[InlineData(1_000_000)] // default cap: used to cite 1,000,000
public async Task A_ceiling_bound_per_sample_read_names_the_gateway_ceiling(int maxRawRows)
{
var ex = await Should.ThrowAsync<HistoryReadLimitExceededException>(() => Source(Store(100, ceiling: 50), maxRawRows)
.ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct));
ex.Message.ShouldContain("row ceiling (50,");
ex.Message.ShouldNotContain("MaxTimeWeightedRawRows); the Gateway");
}
[Fact]
public async Task A_cap_bound_per_sample_read_still_names_the_cap()
{
var ex = await Should.ThrowAsync<HistoryReadLimitExceededException>(() => Source(Store(100, ceiling: null), 20)
.ReadProcessedAsync("T", At(0), At(100), TimeSpan.FromSeconds(10), HistoryAggregateType.Count, Ct));
ex.Message.ShouldContain("more than 20 raw samples");
}
}