Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayServerCappedEventsTests.cs
T
Joseph Doherty 53978ca638 feat(historian-gateway): native TimeAverage/Total on by default, SendEvents batches, server-side max_events (#694, #746)
#694 item 3 — historiangw#27 is fixed and now live-verified through OtOpcUa:
- ServerHistorian:Gateway:EnableNativeTimeWeighted defaults to true (false is
  the kill switch for a gateway older than 9f711c1). TimeAverage / Total are
  served and advertised again (12 aggregates).
- The gateway sends the interval as max(1, round(ms)) and AVEVA clips its last
  interval at the request end (live). NativeAggregatePlan therefore sends a
  multi-interval grid only for whole-ms intervals; a fractional interval is read
  one interval per request (at most 1,000, else BadTooManyOperations), and every
  one-interval request goes out rounded UP to whole ms so AVEVA answers one
  clipped row. Live, a 3.0004 s clipped interval sent at its own width lost its
  last 0.4 ms (Total 123 vs 123.0172).

#746 — HistorianGateway Contracts/Client 1.0.0 surface:
- Alarm history: GatewayAlarmHistorianWriter sends SendEvents batches of <=200.
  A failed per-event result is re-sent alone via SendEvent (classified as
  before); a refused batch (InvalidArgument etc.) goes event by event so a
  poison event dead-letters alone; a systemic fault retries the rest without
  sending more; Unimplemented (older gateway) falls back to SendEvent for good.
  IHistorianGatewayClient.SendEventsAsync defaults to Unimplemented.
- Events reads: a capped page sends cap + 1 as ReadEventsRequest.max_events, so
  the gateway completes the stream instead of the client cancelling it. An
  ambiguous capped answer (foreign rows filtered out of the server's cut) or a
  refused cap is re-read uncapped with the existing EventDrainBudget drain.

Tests: GatewayServerCappedEventsTests, GatewayAlarmWriterBatchTests,
GatewayNativeWholeMillisecondGridTests (gateway-faithful rounding fake), gate
tests flipped to the new default; every new test mutation-checked (each
mutation of the production change fails at least one). Live harness
AvevaGatewayHistorianFollowupsLiveGateTests (read-only) for #694/#700/#746.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 00:09:34 -05:00

130 lines
6.3 KiB
C#

using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// lmxopcua#746: a capped Gateway events read sends cap + 1 as the server-side <c>ReadEventsRequest.max_events</c>, so a
/// current gateway completes the stream normally at the cap (no cancelled RPC, no evicted historian session) and the
/// (cap + 1)-th event is the "more exist" signal. An ambiguous capped answer (foreign rows filtered out of a stream the
/// server cut) and a refused cap (InvalidArgument) are re-read without a server cap. The fake honours
/// <c>max_events</c> the way the gateway does (stop at it, complete normally).
/// </summary>
public sealed class GatewayServerCappedEventsTests
{
private static readonly DateTime T0 = new(2026, 11, 12, 8, 0, 0, DateTimeKind.Utc);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task A_capped_read_asks_the_server_for_cap_plus_one_and_never_abandons_the_stream()
{
// Budget 0 = abandon at the cap on the drain path: only the server cap keeps this stream from being abandoned.
var fake = new FakeHistorianGatewayClient { Events = Series(50) };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, eventDrainBudget: 0);
var r = await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents: 10, Ct);
r.Events.Select(e => e.EventId).ShouldBe(Enumerable.Range(0, 10).Select(i => $"E{i:000}"));
r.ContinuationPoint.ShouldNotBeNull("the 11th event is the 'more exist' signal");
fake.EventReadCaps.ShouldBe([11]);
fake.EventsYieldedCount.ShouldBe(11, "the server stops at max_events — the window's other 39 events never cross the wire");
fake.EventStreamsCompleted.ShouldBe(1);
fake.EventStreamsAbandoned.ShouldBe(0);
ds.EventStreamsAbandoned.ShouldBe(0);
ds.EventServerCapReReads.ShouldBe(0);
}
[Fact]
public async Task A_window_holding_exactly_the_cap_is_complete_in_one_read()
{
var fake = new FakeHistorianGatewayClient { Events = Series(10) };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents: 10, Ct);
r.Events.Count.ShouldBe(10);
r.ContinuationPoint.ShouldBeNull();
fake.EventReadCaps.ShouldBe([11]);
fake.ReadEventsCallCount.ShouldBe(1);
}
[Fact]
public async Task Foreign_rows_inside_the_server_cut_are_re_read_without_the_cap()
{
// The server filter let foreign rows through (the client-side filter drops them): the capped stream ends at
// cap + 1 rows without a matching overflow row, so whether more matching events exist is unknown.
var events = Series(3, "Pump1").Concat(Series(5, "Pump2", offsetSeconds: 10)).Append(Ev(99, "Pump1")).ToArray();
var fake = new FakeHistorianGatewayClient { Events = events };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadEventsAsync("Pump1", T0, T0.AddHours(1), maxEvents: 3, Ct);
r.Events.Count.ShouldBe(3);
r.ContinuationPoint.ShouldNotBeNull("the late Pump1 event exists past the server's cut");
fake.EventReadCaps.ShouldBe([4, 0]);
ds.EventServerCapReReads.ShouldBe(1);
// Negative: without the late matching event the re-read answers complete.
fake.Events = events[..^1];
fake.EventReadCaps.Clear();
(await ds.ReadEventsAsync("Pump1", T0, T0.AddHours(1), maxEvents: 3, Ct)).ContinuationPoint.ShouldBeNull();
fake.EventReadCaps.ShouldBe([4, 0]);
}
[Fact]
public async Task A_refused_server_cap_is_re_read_uncapped_and_is_not_a_backend_failure()
{
var fake = new FakeHistorianGatewayClient { Events = Series(20), MaxEventsCeiling = 5 };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents: 10, Ct);
r.Events.Count.ShouldBe(10);
r.ContinuationPoint.ShouldNotBeNull();
fake.EventReadCaps.ShouldBe([11, 0]);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
// Positive control: a cap under the ceiling goes out as the server cap and is not re-read.
fake.EventReadCaps.Clear();
(await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents: 3, Ct)).Events.Count.ShouldBe(3);
fake.EventReadCaps.ShouldBe([4]);
}
[Theory]
[InlineData(0)]
[InlineData(int.MaxValue)]
[InlineData(GatewayHistorianDataSource.MaxServerCappedEvents + 1)]
public async Task Uncapped_and_over_ceiling_reads_send_no_server_cap(int maxEvents)
{
var fake = new FakeHistorianGatewayClient { Events = Series(5) };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
(await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents, Ct)).Events.Count.ShouldBe(5);
fake.EventReadCaps.ShouldBe([0]);
}
[Fact]
public async Task The_largest_server_capped_page_sends_the_gateway_default_ceiling()
{
var fake = new FakeHistorianGatewayClient { Events = Series(5), MaxEventsCeiling = 1_000_000 };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
await ds.ReadEventsAsync(null, T0, T0.AddHours(1), GatewayHistorianDataSource.MaxServerCappedEvents, Ct);
fake.EventReadCaps.ShouldBe([1_000_000], "cap + 1 must stay within the gateway's default MaxEventsCeiling");
}
private static HistorianEvent[] Series(int count, string source = "Pump1", int offsetSeconds = 0) =>
Enumerable.Range(0, count).Select(i => Ev(i + offsetSeconds, source, $"E{i:000}")).ToArray();
private static HistorianEvent Ev(int seconds, string source, string? id = null) => new()
{
Id = id ?? $"X{seconds}",
SourceName = source,
EventTime = Timestamp.FromDateTime(T0.AddSeconds(seconds)),
ReceivedTime = Timestamp.FromDateTime(T0.AddSeconds(seconds)),
};
}