53978ca638
#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
130 lines
6.3 KiB
C#
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)),
|
|
};
|
|
}
|