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:
+42
-1
@@ -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(
|
||||
|
||||
+184
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -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);
|
||||
|
||||
+3
-2
@@ -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
|
||||
{
|
||||
|
||||
+18
-13
@@ -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)
|
||||
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
+129
@@ -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)),
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user