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
This commit is contained in:
Joseph Doherty
2026-11-12 23:57:38 -05:00
parent 09b373e4db
commit 53978ca638
22 changed files with 1847 additions and 176 deletions
@@ -211,7 +211,48 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient
LastReadEventsEndUtc = endUtc;
LastReadEventsMaxEvents = maxEvents;
ReadEventsCallCount++;
return CountingEventStream(ToAsyncStream(EventsSource is null ? Events : EventsSource(startUtc, endUtc).ToList(), ThrowOnRead, ct));
lock (EventReadCaps) EventReadCaps.Add(maxEvents);
if (MaxEventsCeiling is { } ceiling && maxEvents > ceiling)
{
// The real gateway's refusal (HistorianReadService.ReadEvents: max_events over MaxEventsCeiling), raised before
// any row, as the client package surfaces it.
var rpc = new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
$"max_events exceeds the server ceiling of {ceiling}."));
return CountingEventStream(ToAsyncStream(Array.Empty<HistorianEvent>(),
new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException(rpc.Status.Detail, rpc), ct));
}
IReadOnlyList<HistorianEvent> items = EventsSource is null ? Events : EventsSource(startUtc, endUtc).ToList();
// A current gateway stops at max_events and completes the stream normally (historiangw#53); IgnoreMaxEvents
// simulates an older gateway that streams everything.
if (maxEvents > 0 && !IgnoreMaxEvents && items.Count > maxEvents) items = items.Take(maxEvents).ToList();
return CountingEventStream(ToAsyncStream(items, ThrowOnRead, ct));
}
/// <summary>Simulates a gateway older than <c>max_events</c>: every event in the window is streamed.</summary>
public bool IgnoreMaxEvents;
/// <summary>When set, a ReadEvents whose max_events exceeds it is refused <c>InvalidArgument</c> (MaxEventsCeiling).</summary>
public int? MaxEventsCeiling;
/// <summary>Every ReadEvents call's maxEvents (the server-side max_events sent), in order.</summary>
public readonly List<int> EventReadCaps = new();
// ---- SendEvents ----------------------------------------------------------------------------
/// <summary>
/// When set, SendEvents is served by it. Unset, SendEvents answers <c>Unimplemented</c> (the seam's default — an
/// older gateway), so the writer falls back to SendEvent.
/// </summary>
public Func<IReadOnlyList<HistorianEvent>, CancellationToken, Task<SendEventsReply>>? SendEventsSource;
/// <summary>Every SendEvents call's events, in order.</summary>
public readonly List<IReadOnlyList<HistorianEvent>> SendEventsCalls = new();
public Task<SendEventsReply> SendEventsAsync(IReadOnlyList<HistorianEvent> events, CancellationToken ct)
{
lock (SendEventsCalls) SendEventsCalls.Add(events);
if (SendEventsSource is { } source) return source(events, ct);
return Task.FromException<SendEventsReply>(new Grpc.Core.RpcException(
new Grpc.Core.Status(Grpc.Core.StatusCode.Unimplemented, "Method is unimplemented.")));
}
public Task<WriteAck> WriteLiveValuesAsync(
@@ -0,0 +1,184 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Client;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// lmxopcua#746: alarm-history writes go to the gateway as <c>SendEvents</c> batches (Contracts/Client 0.4.0+) — one
/// round trip per <see cref="GatewayAlarmHistorianWriter.MaxEventsPerBatch"/> events — with per-event outcomes in input
/// order. A failed per-event result is classified by a single <c>SendEvent</c> re-send; a refused batch is sent event by
/// event (a poison event dead-letters alone); a systemic fault retries the rest without sending more; an older gateway
/// (<c>Unimplemented</c>) falls back to single sends for good.
/// </summary>
public sealed class GatewayAlarmWriterBatchTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static AlarmHistorianEvent Evt(int i) => new(
$"alarm-{i}", "Area/Pump", "N", "LimitAlarm", AlarmSeverity.High, "Activated", $"m{i}", "u", null,
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(i));
private static int MessageIndex(HistorianEvent e) => int.Parse(e.Properties["Message"][1..]);
private static SendEventsReply AllOk(IReadOnlyList<HistorianEvent> events)
{
var reply = new SendEventsReply();
foreach (var e in events) reply.Results.Add(new EventWriteResult { Id = e.Id, Success = true });
return reply;
}
[Fact]
public async Task A_batch_goes_out_as_SendEvents_calls_of_at_most_the_batch_size_with_no_single_sends()
{
var fake = new FakeHistorianGatewayClient { SendEventsSource = (events, _) => Task.FromResult(AllOk(events)) };
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
const int n = 407;
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, n).Select(Evt).ToList(), Ct);
outcomes.ShouldAllBe(o => o == HistorianWriteOutcome.Ack);
outcomes.Count.ShouldBe(n);
// 200 per call: well inside the gateway's 10,000-event / 16 MiB request limits and the 4 MiB gRPC message default.
fake.SendEventsCalls.Select(c => c.Count).ShouldBe([200, 200, 7]);
fake.SendEventsCalls.SelectMany(c => c).Select(MessageIndex).ShouldBe(Enumerable.Range(0, n), "request order = input order");
fake.SendEventCallCount.ShouldBe(0, "no per-event round trips when the batch RPC answers");
writer.BatchUnsupported.ShouldBeFalse();
}
[Fact]
public async Task Queued_results_ack_and_a_failed_result_is_classified_by_a_single_re_send()
{
var fake = new FakeHistorianGatewayClient
{
SendEventsSource = (events, _) =>
{
var reply = new SendEventsReply();
foreach (var e in events)
reply.Results.Add(MessageIndex(e) switch
{
1 => new EventWriteResult { Id = e.Id, Success = true, Queued = true },
2 or 3 => new EventWriteResult { Id = e.Id, Success = false, Error = "Historian did not accept the write." },
_ => new EventWriteResult { Id = e.Id, Success = true },
});
return Task.FromResult(reply);
},
// The single re-send: event 2 is rejected by the historian (FailedPrecondition, as SendEvent answers a Failed
// write), event 3 is transient.
SendEventSource = (e, _) => MessageIndex(e) == 2
? Task.FromException<WriteAck>(new RpcException(new Status(StatusCode.FailedPrecondition, "rejected")))
: Task.FromException<WriteAck>(new HistorianGatewayUnavailableException("down", new RpcException(new Status(StatusCode.Unavailable, "down")))),
};
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, 5).Select(Evt).ToList(), Ct);
outcomes.ShouldBe([
HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack, HistorianWriteOutcome.PermanentFail,
HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.Ack,
]);
fake.SendEventsCalls.Count.ShouldBe(1);
fake.SendEventCallCount.ShouldBe(2, "only the two failed events are re-sent");
}
[Fact]
public async Task A_refused_batch_is_sent_event_by_event_so_a_poison_event_dead_letters_alone()
{
var fake = new FakeHistorianGatewayClient
{
// The gateway validates the whole request first: one bad event refuses the batch, nothing sent.
SendEventsSource = (_, _) => Task.FromException<SendEventsReply>(
new HistorianGatewayException("events[2].id must be empty or a GUID.",
new RpcException(new Status(StatusCode.InvalidArgument, "events[2].id must be empty or a GUID.")))),
SendEventSource = (e, _) => MessageIndex(e) == 2
? Task.FromException<WriteAck>(new RpcException(new Status(StatusCode.InvalidArgument, "id must be empty or a GUID.")))
: Task.FromResult(new WriteAck { Success = true }),
};
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, 4).Select(Evt).ToList(), Ct);
outcomes.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack, HistorianWriteOutcome.PermanentFail, HistorianWriteOutcome.Ack]);
fake.SendEventCallCount.ShouldBe(4);
writer.BatchUnsupported.ShouldBeFalse("a refused batch is not an older gateway — the next drain batches again");
}
[Fact]
public async Task A_systemic_fault_retries_the_rest_and_sends_nothing_more()
{
var calls = 0;
var fake = new FakeHistorianGatewayClient
{
SendEventsSource = (events, _) => Interlocked.Increment(ref calls) == 1
? Task.FromResult(AllOk(events))
: Task.FromException<SendEventsReply>(new HistorianGatewayUnavailableException("down",
new RpcException(new Status(StatusCode.Unavailable, "down")))),
};
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var size = GatewayAlarmHistorianWriter.MaxEventsPerBatch;
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, size * 3).Select(Evt).ToList(), Ct);
outcomes.Take(size).ShouldAllBe(o => o == HistorianWriteOutcome.Ack);
outcomes.Skip(size).ShouldAllBe(o => o == HistorianWriteOutcome.RetryPlease);
calls.ShouldBe(2, "the third chunk would hit the same outage");
fake.SendEventCallCount.ShouldBe(0, "a systemic fault must not fan out into per-event sends");
}
[Fact]
public async Task An_older_gateway_falls_back_to_single_sends_for_good()
{
// No SendEventsSource: the fake answers Unimplemented, as a gateway older than the RPC.
var fake = new FakeHistorianGatewayClient();
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
(await writer.WriteBatchAsync(Enumerable.Range(0, 5).Select(Evt).ToList(), Ct)).ShouldAllBe(o => o == HistorianWriteOutcome.Ack);
(await writer.WriteBatchAsync(Enumerable.Range(5, 5).Select(Evt).ToList(), Ct)).ShouldAllBe(o => o == HistorianWriteOutcome.Ack);
writer.BatchUnsupported.ShouldBeTrue();
fake.SendEventsCalls.Count.ShouldBe(1, "Unimplemented is remembered — the second drain batch goes straight to single sends");
fake.SendEventCallCount.ShouldBe(10);
}
[Fact]
public async Task A_cancellation_during_the_batch_call_retries_and_dead_letters_nothing()
{
using var cts = new CancellationTokenSource();
var fake = new FakeHistorianGatewayClient
{
SendEventsSource = async (_, ct) =>
{
await cts.CancelAsync();
ct.ThrowIfCancellationRequested();
return new SendEventsReply();
},
};
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, GatewayAlarmHistorianWriter.MaxEventsPerBatch + 3).Select(Evt).ToList(), cts.Token);
outcomes.ShouldAllBe(o => o == HistorianWriteOutcome.RetryPlease);
fake.SendEventsCalls.Count.ShouldBe(1);
fake.SendEventCallCount.ShouldBe(0);
}
[Fact]
public async Task A_short_reply_classifies_the_missing_events_by_single_sends()
{
var fake = new FakeHistorianGatewayClient
{
SendEventsSource = (events, _) => Task.FromResult(AllOk(events.Take(2).ToList())),
};
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, 4).Select(Evt).ToList(), Ct);
outcomes.ShouldAllBe(o => o == HistorianWriteOutcome.Ack);
fake.SendEventCallCount.ShouldBe(2);
}
}
@@ -8,9 +8,11 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// The Gateway events read drains its stream past the cap (within a budget) instead of abandoning it — an abandoned
/// gateway stream cancels the RPC and the gateway evicts its pooled historian session — and a caller cancellation on
/// the events / native-AtTime arms is not a backend failure.
/// Against a stream that runs past the <c>max_events</c> it was sent (a seam that ignores it), or the uncapped fallback
/// read, the Gateway events read drains its stream past the cap (within a budget) instead of abandoning it — an abandoned
/// gateway stream cancels the RPC and the gateway evicts its pooled historian session — and a caller cancellation on the
/// events / native-AtTime arms is not a backend failure. The server-side <c>max_events</c> path is
/// <see cref="GatewayServerCappedEventsTests"/>.
/// </summary>
public sealed class GatewayEventDrainTests
{
@@ -20,7 +22,7 @@ public sealed class GatewayEventDrainTests
[Fact]
public async Task A_capped_read_drains_the_stream_to_its_end_and_still_flags_truncation()
{
var fake = new FakeHistorianGatewayClient { Events = Series(50) };
var fake = new FakeHistorianGatewayClient { Events = Series(50), IgnoreMaxEvents = true };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents: 10, Ct);
@@ -36,7 +38,7 @@ public sealed class GatewayEventDrainTests
[Fact]
public async Task Past_the_drain_budget_the_stream_is_abandoned_but_the_page_is_unchanged()
{
var fake = new FakeHistorianGatewayClient { Events = Series(100) };
var fake = new FakeHistorianGatewayClient { Events = Series(100), IgnoreMaxEvents = true };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, eventDrainBudget: 5);
var r = await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents: 10, Ct);
@@ -44,21 +46,21 @@ public sealed class GatewayEventDrainTests
r.Events.Count.ShouldBe(10);
r.ContinuationPoint.ShouldNotBeNull();
fake.EventStreamsAbandoned.ShouldBe(1);
fake.EventsYieldedCount.ShouldBe(10 + 5 + 1, "cap + budget, then the row that overran it");
fake.EventsYieldedCount.ShouldBe(10 + 1 + 5 + 1, "the max_events it was sent (cap + 1), the budget, then the row that overran it");
ds.EventStreamsAbandoned.ShouldBe(1);
}
[Fact]
public async Task Budget_zero_is_the_previous_abandon_at_the_cap()
{
var fake = new FakeHistorianGatewayClient { Events = Series(20) };
var fake = new FakeHistorianGatewayClient { Events = Series(20), IgnoreMaxEvents = true };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, eventDrainBudget: 0);
var r = await ds.ReadEventsAsync(null, T0, T0.AddHours(1), maxEvents: 3, Ct);
r.Events.Count.ShouldBe(3);
r.ContinuationPoint.ShouldNotBeNull();
fake.EventsYieldedCount.ShouldBe(4);
fake.EventsYieldedCount.ShouldBe(5, "the max_events it was sent (cap + 1), then the row past it");
fake.EventStreamsAbandoned.ShouldBe(1);
}
@@ -67,7 +69,7 @@ public sealed class GatewayEventDrainTests
{
// The page is exactly the matching events; everything after it belongs to another source (an unfiltered gateway).
var events = Series(3, "Pump1").Concat(Series(5, "Pump2", offsetSeconds: 10)).ToArray();
var fake = new FakeHistorianGatewayClient { Events = events };
var fake = new FakeHistorianGatewayClient { Events = events, IgnoreMaxEvents = true };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var r = await ds.ReadEventsAsync("Pump1", T0, T0.AddHours(1), maxEvents: 3, Ct);
@@ -15,8 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// first); a forward read's clipped LATEST interval is read over exactly its span (review). The fake is a native-mode AVEVA
/// model: a step-held stored series; for a forward request <c>[s, e)</c> with interval <c>I</c> it answers one row per
/// interval of the grid anchored at <c>s</c> (plus AVEVA's extra row for the interval before <c>s</c>, #685), each the
/// time-weighted average / integral of exactly that interval — the per-interval behaviour the gateway will have once
/// historiangw#27 is fixed.
/// time-weighted average / integral of exactly that interval — the per-interval behaviour the gateway has since
/// historiangw#27 (live-verified, lmxopcua#694). The gateway's whole-millisecond interval rounding is
/// <see cref="GatewayNativeWholeMillisecondGridTests"/>.
/// </summary>
public sealed class GatewayNativeAggregateGridTests
{
@@ -9,9 +9,9 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// AVEVA's native TimeAverage / Total answer Good-status wrong values until the gateway's interval fix
/// (historiangw#27) is live-verified, so they are refused <c>BadAggregateNotSupported</c> unless
/// <c>ServerHistorian:Gateway:EnableNativeTimeWeighted</c> opts back in.
/// AVEVA's native TimeAverage / Total are served by default since the gateway's interval fix (historiangw#27) was
/// live-verified (lmxopcua#694); <c>ServerHistorian:Gateway:EnableNativeTimeWeighted=false</c> is the kill switch that
/// refuses them <c>BadAggregateNotSupported</c> (for a gateway older than the fix).
/// </summary>
public sealed class GatewayNativeTimeWeightedGateTests
{
@@ -21,10 +21,10 @@ public sealed class GatewayNativeTimeWeightedGateTests
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.Total)]
public async Task By_default_the_native_time_weighted_pair_is_refused_unsent_and_unadvertised(HistoryAggregateType a)
public async Task The_kill_switch_refuses_the_native_time_weighted_pair_unsent_and_unadvertised(HistoryAggregateType a)
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: false);
ds.SupportedAggregates.ShouldNotContain(a);
await Should.ThrowAsync<HistoryAggregateNotSupportedException>(
@@ -43,10 +43,10 @@ public sealed class GatewayNativeTimeWeightedGateTests
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.Total)]
public async Task Opting_in_serves_and_advertises_the_native_pair(HistoryAggregateType a)
public async Task By_default_the_native_pair_is_served_and_advertised(HistoryAggregateType a)
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.ServedAggregates);
await ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(1), a, Ct);
@@ -54,17 +54,22 @@ public sealed class GatewayNativeTimeWeightedGateTests
}
[Fact]
public void The_default_set_is_the_ten_raw_evaluated_aggregates()
public void The_default_set_is_all_twelve_and_the_kill_switch_leaves_the_ten_raw_evaluated()
{
var ds = new GatewayHistorianDataSource(new FakeHistorianGatewayClient(), NullLogger<GatewayHistorianDataSource>.Instance);
ds.SupportedAggregates.Count.ShouldBe(10);
ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.RawEvaluatedAggregates);
GatewayHistorianDataSource.ServedAggregates.Except(ds.SupportedAggregates).OrderBy(x => x)
var on = new GatewayHistorianDataSource(new FakeHistorianGatewayClient(), NullLogger<GatewayHistorianDataSource>.Instance);
on.SupportedAggregates.Count.ShouldBe(12);
on.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.ServedAggregates);
var off = new GatewayHistorianDataSource(new FakeHistorianGatewayClient(), NullLogger<GatewayHistorianDataSource>.Instance,
enableNativeTimeWeighted: false);
off.SupportedAggregates.Count.ShouldBe(10);
off.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.RawEvaluatedAggregates);
GatewayHistorianDataSource.ServedAggregates.Except(off.SupportedAggregates).OrderBy(x => x)
.ShouldBe([HistoryAggregateType.Total, HistoryAggregateType.TimeAverage]);
}
[Theory]
[InlineData(null, false)]
[InlineData(null, true)]
[InlineData("false", false)]
[InlineData("true", true)]
public void The_flag_binds_from_configuration_and_reaches_the_data_source(string? configured, bool expected)
@@ -0,0 +1,180 @@
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>
/// lmxopcua#694: the gateway sends AVEVA the native interval in WHOLE milliseconds (<c>max(1, round(ms))</c>) and AVEVA
/// clips its last interval at the request's end — both observed live (a 1.5 ms interval answers on a 2 ms grid; a 0.4 ms
/// window answers one row over exactly 0.4 ms). So a multi-interval request is only correct for a whole-millisecond
/// interval: a fractional one is read one interval per request, up to
/// <see cref="NativeAggregatePlan.MaxPerIntervalRequests"/>, and every one-interval request is sent with its width
/// rounded UP to whole milliseconds so AVEVA answers it as ONE clipped row. The fake models exactly that gateway + AVEVA
/// behaviour (the gateway's round-half-to-even included) over a step-held series.
/// </summary>
public sealed class GatewayNativeWholeMillisecondGridTests
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly DateTime T0 = new(2026, 11, 11, 14, 2, 3, DateTimeKind.Utc);
private const uint GoodCalc = 0x00000401u;
private const uint Partial = 0x4u;
/// <summary>A value that changes every 0.5 ms (1, 2, 3, …): any wrong grid shows in the values.</summary>
private static double ValueAt(DateTime t) => 1 + Math.Floor((t - T0).Ticks / 5000.0);
private static double Integral(DateTime a, DateTime b)
{
double sum = 0;
for (var t = a; t < b;)
{
var next = T0.AddTicks(((t - T0).Ticks / 5000 + 1) * 5000);
if (next > b) next = b;
sum += ValueAt(t) * (next - t).TotalSeconds;
t = next;
}
return sum;
}
private static double Expected(RetrievalMode mode, DateTime a, DateTime b) =>
mode == RetrievalMode.Integral ? Integral(a, b) : Integral(a, b) / (b - a).TotalSeconds;
/// <summary>The gateway (whole-ms interval) in front of AVEVA (grid anchored at the request start, last interval clipped).</summary>
private static FakeHistorianGatewayClient GatewayAndAveva() => new()
{
AggregateSource = (s, e, mode, interval) =>
{
var wire = TimeSpan.FromMilliseconds(Math.Max(1, Math.Round(interval.TotalMilliseconds)));
var rows = new List<HistorianAggregateSample>();
for (var lo = s - wire; lo < e; lo += wire) // AVEVA's extra pre-window row first
{
var hi = lo + wire > e && lo >= s ? e : lo + wire;
rows.Add(new HistorianAggregateSample
{
Tag = "T",
StartTime = Timestamp.FromDateTime(lo),
EndTime = Timestamp.FromDateTime(hi),
Value = Expected(mode, lo, hi),
OpcQuality = 192,
RetrievalMode = mode,
});
}
return rows;
},
};
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) =>
new(fake, NullLogger<GatewayHistorianDataSource>.Instance);
[Theory]
[InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)]
[InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)]
public async Task A_fractional_millisecond_interval_is_read_one_interval_per_request(HistoryAggregateType agg, RetrievalMode mode)
{
var fake = GatewayAndAveva();
var interval = TimeSpan.FromTicks(15_000); // 1.5 ms — the gateway would send 2 ms
var s = (await Source(fake).ReadProcessedAsync("T", T0, T0.AddMilliseconds(6), interval, agg, Ct)).Samples;
s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { T0, T0.AddTicks(15_000), T0.AddTicks(30_000), T0.AddTicks(45_000) });
s.Select(x => (double)x.Value!).ShouldBe(
Enumerable.Range(0, 4).Select(i => Expected(mode, T0.AddTicks(i * 15_000L), T0.AddTicks((i + 1) * 15_000L))).ToList(),
tolerance: 1e-9);
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
fake.AggregateCalls.Select(c => (c.Start, c.End)).ShouldBe(
Enumerable.Range(0, 4).Select(i => (T0.AddTicks(i * 15_000L), T0.AddTicks((i + 1) * 15_000L))).ToList());
}
[Fact]
public async Task A_fractional_interval_reverse_read_is_exact_too()
{
var fake = GatewayAndAveva();
var m = RetrievalMode.TimeWeightedAverage;
var s = (await Source(fake).ReadProcessedAsync("T", T0.AddTicks(40_000), T0, TimeSpan.FromTicks(15_000),
HistoryAggregateType.TimeAverage, Ct)).Samples;
// (0, 4 ms] stepping back from 4 ms: (2.5, 4], (1, 2.5], and the clipped (0, 1] — newest first, later edges.
s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { T0.AddTicks(40_000), T0.AddTicks(25_000), T0.AddTicks(10_000) });
s.Select(x => (double)x.Value!).ShouldBe(
[Expected(m, T0.AddTicks(25_000), T0.AddTicks(40_000)), Expected(m, T0.AddTicks(10_000), T0.AddTicks(25_000)),
Expected(m, T0, T0.AddTicks(10_000))],
tolerance: 1e-9);
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc | Partial]);
fake.ReadAggregateCallCount.ShouldBe(3);
}
[Fact]
public async Task A_whole_millisecond_interval_stays_one_request_with_a_sub_millisecond_clipped_interval()
{
var fake = GatewayAndAveva();
var m = RetrievalMode.Integral;
var s = (await Source(fake).ReadProcessedAsync("T", T0, T0.AddTicks(30_004), TimeSpan.FromMilliseconds(1),
HistoryAggregateType.Total, Ct)).Samples;
s.Count.ShouldBe(4);
s.Select(x => (double)x.Value!).ShouldBe(
[Expected(m, T0, T0.AddMilliseconds(1)), Expected(m, T0.AddMilliseconds(1), T0.AddMilliseconds(2)),
Expected(m, T0.AddMilliseconds(2), T0.AddMilliseconds(3)), Expected(m, T0.AddMilliseconds(3), T0.AddTicks(30_004))],
tolerance: 1e-12);
s[^1].StatusCode.ShouldBe(GoodCalc | Partial);
fake.AggregateCalls.Select(c => (c.Start, c.End, c.Interval)).ShouldBe([
(T0, T0.AddMilliseconds(3), TimeSpan.FromMilliseconds(1)),
(T0.AddMilliseconds(3), T0.AddTicks(30_004), TimeSpan.FromMilliseconds(1)), // the 0.4 µs width, rounded UP to 1 ms
]);
}
[Theory]
[InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)]
[InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)]
public async Task A_clipped_interval_that_is_not_whole_milliseconds_is_one_row_over_its_whole_span(HistoryAggregateType agg,
RetrievalMode mode)
{
// Live (lmxopcua#694): [40 s, 43.0004 s) sent at its own width reached AVEVA as 3,000 ms and answered a 3 s row plus a
// 0.4 ms one; the first won, so the clipped Total lost its last 0.4 ms. Here: 4 ms intervals, a 2.0004 ms clipped one.
var fake = GatewayAndAveva();
var end = T0.AddTicks(60_004);
var s = (await Source(fake).ReadProcessedAsync("T", T0, end, TimeSpan.FromMilliseconds(4), agg, Ct)).Samples;
s.Count.ShouldBe(2);
((double)s[1].Value!).ShouldBe(Expected(mode, T0.AddMilliseconds(4), end), 1e-12);
s[1].StatusCode.ShouldBe(GoodCalc | Partial);
fake.AggregateCalls[^1].ShouldBe((T0.AddMilliseconds(4), end, mode, TimeSpan.FromMilliseconds(3)));
}
[Fact]
public async Task A_half_millisecond_interval_rounding_to_even_still_reads_each_interval_whole()
{
// 2.5 ms rounds (to even) to 2 ms on the gateway: sent at its width, each interval would answer a 2 ms row + 0.5 ms.
var fake = GatewayAndAveva();
var m = RetrievalMode.Integral;
var s = (await Source(fake).ReadProcessedAsync("T", T0, T0.AddMilliseconds(5), TimeSpan.FromTicks(25_000),
HistoryAggregateType.Total, Ct)).Samples;
s.Select(x => (double)x.Value!).ShouldBe(
[Expected(m, T0, T0.AddTicks(25_000)), Expected(m, T0.AddTicks(25_000), T0.AddMilliseconds(5))], tolerance: 1e-12);
fake.AggregateCalls.Select(c => c.Interval).ShouldAllBe(i => i == TimeSpan.FromMilliseconds(3));
}
[Fact]
public async Task Too_many_fractional_intervals_are_a_request_limit_unsent_and_not_a_backend_failure()
{
var fake = GatewayAndAveva();
var ds = Source(fake);
var interval = TimeSpan.FromTicks(15_000);
var end = T0.AddTicks(15_000L * (NativeAggregatePlan.MaxPerIntervalRequests + 1));
var ex = await Should.ThrowAsync<HistoryReadLimitExceededException>(
() => ds.ReadProcessedAsync("T", T0, end, interval, HistoryAggregateType.TimeAverage, Ct));
ex.Limit.ShouldBe(NativeAggregatePlan.MaxPerIntervalRequests);
fake.ReadAggregateCallCount.ShouldBe(0);
fake.TagExistsCallCount.ShouldBe(0);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
// Positive control: exactly the limit is served.
var ok = await ds.ReadProcessedAsync("T", T0, T0.AddTicks(15_000L * NativeAggregatePlan.MaxPerIntervalRequests), interval,
HistoryAggregateType.TimeAverage, Ct);
ok.Samples.Count.ShouldBe(NativeAggregatePlan.MaxPerIntervalRequests);
}
}
@@ -0,0 +1,129 @@
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)),
};
}
@@ -51,7 +51,7 @@ public sealed class AggregateModeMapperTests
}
Assert.Equal(12, GatewayHistorianDataSource.ServedAggregates.Count);
// The native pair is opt-in (ServerHistorian:Gateway:EnableNativeTimeWeighted) — see GatewayNativeTimeWeightedGateTests.
// The native pair follows ServerHistorian:Gateway:EnableNativeTimeWeighted (default on) — see GatewayNativeTimeWeightedGateTests.
var source = new GatewayHistorianDataSource(
new FakeHistorianGatewayClient(), Microsoft.Extensions.Logging.Abstractions.NullLogger<GatewayHistorianDataSource>.Instance,
enableNativeTimeWeighted: true);