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);
|
||||
|
||||
+9
-8
@@ -150,24 +150,25 @@ public sealed class HistorianBackendRegistryTests
|
||||
IHistorianDataSource Source(ServerHistorianOptions o, IConfiguration c) =>
|
||||
HistorianBackendRegistry.Resolve(o, c).CreateDataSource(sp);
|
||||
|
||||
// #681: the ten per-sample aggregates (raw-evaluated). The two AVEVA-native ones (TimeAverage / Total) are
|
||||
// refused and not advertised by default until historiangw#27 is fixed (#746, R08:2.1) ...
|
||||
// #681: the ten per-sample aggregates (raw-evaluated), plus — by default since historiangw#27 was live-verified
|
||||
// (lmxopcua#694) — the two AVEVA-native ones (TimeAverage / Total) ...
|
||||
HistoryAggregateType[] gatewayRawEvaluated =
|
||||
[
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Count, HistoryAggregateType.Minimum,
|
||||
HistoryAggregateType.Maximum, HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
|
||||
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
|
||||
];
|
||||
Source(Gateway(), Cfg).SupportedAggregates.ShouldBe(gatewayRawEvaluated, ignoreOrder: true);
|
||||
Source(Gateway(), Cfg).SupportedAggregates.ShouldBe(
|
||||
[.. gatewayRawEvaluated, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total], ignoreOrder: true);
|
||||
|
||||
// ... and served again, through the Host's resolve path, once ServerHistorian:Gateway:EnableNativeTimeWeighted is on.
|
||||
var nativeOn = new ServerHistorianOptions
|
||||
// ... and refused + unadvertised again, through the Host's resolve path, with the
|
||||
// ServerHistorian:Gateway:EnableNativeTimeWeighted=false kill switch (a gateway older than the #27 fix).
|
||||
var nativeOff = new ServerHistorianOptions
|
||||
{
|
||||
Backend = "Gateway", Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
||||
Gateway = new GatewayHistorianSettings { EnableNativeTimeWeighted = true },
|
||||
Gateway = new GatewayHistorianSettings { EnableNativeTimeWeighted = false },
|
||||
};
|
||||
Source(nativeOn, Cfg).SupportedAggregates.ShouldBe(
|
||||
[.. gatewayRawEvaluated, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total], ignoreOrder: true);
|
||||
Source(nativeOff, Cfg).SupportedAggregates.ShouldBe(gatewayRawEvaluated, ignoreOrder: true);
|
||||
Source(new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }, SqlCfg())
|
||||
.SupportedAggregates.ShouldBe(SqlServed, ignoreOrder: true);
|
||||
Source(new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }, QuestDbCfg())
|
||||
|
||||
+3
-2
@@ -20,11 +20,12 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// </summary>
|
||||
public sealed class AdvertisedAggregateFunctionsWireTests
|
||||
{
|
||||
// #681: the ten per-sample aggregates (raw-evaluated). AVEVA-native TimeAverage / Total are no longer advertised by
|
||||
// default (lmxopcua#746 refuses them until historiangw#27 is fixed and verified; EnableNativeTimeWeighted=true restores them).
|
||||
// #681: the ten per-sample aggregates (raw-evaluated) plus the AVEVA-native TimeAverage / Total, advertised by default
|
||||
// again since historiangw#27 was live-verified (lmxopcua#694; EnableNativeTimeWeighted=false is the kill switch).
|
||||
private static readonly string[] GatewayNames =
|
||||
[
|
||||
"Average", "Count", "Delta", "End", "Maximum", "MaximumActualTime", "Minimum", "MinimumActualTime", "Range", "Start",
|
||||
"TimeAverage", "Total",
|
||||
];
|
||||
|
||||
// #820 added DurationBad / DurationGood (the PercentBad / PercentGood region times, in ms).
|
||||
|
||||
+817
@@ -0,0 +1,817 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Live gate for the historian follow-ups lmxopcua#694 (item 3), #700 and #746 (item 1): the booted OtOpcUa server with
|
||||
/// the <b>production</b> Gateway backend (<see cref="HistorianBackendRegistry"/>, default <see cref="ServerHistorianOptions"/>)
|
||||
/// in front of a REAL HistorianGateway and the real AVEVA Historian, read over a real SDK client session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Read-only.</b> Only HistoryRead (raw, processed, at-time, events) is sent; the Gateway backend has no updater,
|
||||
/// and no provisioner, recorder or alarm writer is built.</para>
|
||||
/// <para><b>What it checks.</b></para>
|
||||
/// <list type="bullet">
|
||||
/// <item>#694.3 — native <c>TimeAverage</c> / <c>Total</c> (now on by default): forward and reverse over whole
|
||||
/// intervals, a clipped interval, a sub-millisecond clipped interval and a fractional-millisecond interval on
|
||||
/// <c>SysTimeSec</c>, each bucket checked against an independent step-held integral of the tag's own raw rows; and
|
||||
/// an outage window on a float tag whose middle intervals hold no good time at all (all-NULL ⇒ <c>BadNoData</c>).</item>
|
||||
/// <item>#700 — missing and existing tag Raw / Processed / AtTime; history values typed as the node's DataType
|
||||
/// (Int32, Boolean, Float); future AtTime keeps the requested timestamps and order; a transport outage answers
|
||||
/// <c>BadNoCommunication</c> while unsupported operations keep their own status.</item>
|
||||
/// <item>#746.1 — events paging over the wire equals the whole read, through a data source whose
|
||||
/// <c>EventDrainBudget</c> is 0: with the server-side <c>max_events</c> no page ever abandons its gateway stream.</item>
|
||||
/// </list>
|
||||
/// <para><b>Env:</b> <c>HISTGW_GATEWAY_ENDPOINT</c> and <c>HISTGW_GATEWAY_APIKEY</c> (any value against a gateway with
|
||||
/// API-key auth disabled) — skips when unset; optional <c>OTOPCUA_AVEVA_GATE_OUT</c>, <c>OTOPCUA_AVEVA_GATE_LABEL</c>.
|
||||
/// The strict-ceiling fixture (<see cref="Strict_ceiling_fixture_stopped_tag_reverse_cost_and_raised_lowered_limits"/>)
|
||||
/// additionally needs <c>OTOPCUA_AVEVA_GATE_STRICT_ENDPOINT</c> (a gateway with <c>AllowStreamingBeyondCeiling=false</c>)
|
||||
/// and <c>OTOPCUA_AVEVA_GATE_STRICT_SET_CEILING</c> (a script that restarts that gateway with the ceiling given as its
|
||||
/// argument). The windows are fixed past DEV (wonder-sql-vd03) windows.</para>
|
||||
/// </remarks>
|
||||
public sealed class AvevaGatewayHistorianFollowupsLiveGateTests
|
||||
{
|
||||
private const string SysTimeSec = "SysTimeSec";
|
||||
private const string SysPulse = "SysPulse";
|
||||
private const string FloatTag = "ZIPSEN.WTC1";
|
||||
private const string MissingTag = "OtOpcUa.Gate694.DoesNotExist";
|
||||
private const string EventSource = "DevWinPlatform";
|
||||
|
||||
private static readonly DateTime S0 = Utc("2026-11-11T14:00:00Z");
|
||||
// W1 on DEV: ZIPSEN.WTC1 is stored NULL at 11:32:36.073 (platform stop), then nothing until 11:38:34.853.
|
||||
private static readonly DateTime OutageStart = Utc("2026-11-10T11:31:00Z");
|
||||
private static readonly DateTime OutageEnd = Utc("2026-11-10T11:40:00Z");
|
||||
private static readonly (DateTime Start, DateTime End) EventWindow = (Utc("2026-11-05T15:00:00Z"), Utc("2026-11-12T00:00:00Z"));
|
||||
|
||||
private const uint GoodCalculated = 0x00000401u;
|
||||
private const uint PartialBit = 0x00000004u;
|
||||
|
||||
[Fact]
|
||||
public async Task Native_time_weighted_typed_history_outage_and_event_paging_over_the_wire()
|
||||
{
|
||||
var endpoint = Env("HISTGW_GATEWAY_ENDPOINT");
|
||||
var apiKey = Env("HISTGW_GATEWAY_APIKEY");
|
||||
if (endpoint is null || apiKey is null)
|
||||
Assert.Skip("Set HISTGW_GATEWAY_ENDPOINT and HISTGW_GATEWAY_APIKEY to run the #694/#700/#746 AVEVA gate.");
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var options = Options(endpoint!, apiKey!);
|
||||
options.Gateway.EnableNativeTimeWeighted.ShouldBeTrue("native TimeAverage / Total are on by default since #694");
|
||||
var provider = HistorianBackendRegistry.Resolve(options, new ConfigurationBuilder().Build());
|
||||
provider.CreateUpdater(new ServiceCollection().BuildServiceProvider()).ShouldBeNull();
|
||||
using var source = provider.CreateDataSource(new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
var report = new Report(Env("OTOPCUA_AVEVA_GATE_LABEL") ?? "unlabelled", endpoint!);
|
||||
var failures = new List<string>();
|
||||
|
||||
await using var rig = await StartAsync("AvevaGate694", encrypted: false, ct);
|
||||
rig.Nm.HistorianDataSource = source;
|
||||
rig.Nm.MaxTieClusterOverfetch = options.MaxTieClusterOverfetch;
|
||||
rig.Nm.HonorReturnBounds = options.HonorReturnBounds;
|
||||
rig.Nm.EventReadHorizon = options.EventReadHorizon;
|
||||
rig.Nm.HistoryReadBatchParallelism = options.HistoryReadBatchParallelism;
|
||||
rig.Nm.MaxConcurrentHistoryReadBatches = options.MaxConcurrentHistoryReadBatches;
|
||||
rig.Nm.HistoryReadDeadline = options.HistoryReadDeadline;
|
||||
rig.Enforce(false, [Key()]);
|
||||
|
||||
var intNode = rig.EnsureRawVariable("HistInt", "Int32", writable: false, SysTimeSec);
|
||||
var boolNode = rig.EnsureRawVariable("HistBool", "Boolean", writable: false, SysPulse);
|
||||
var floatNode = rig.EnsureRawVariable("HistFloat", "Float", writable: false, FloatTag);
|
||||
var dblNode = rig.EnsureRawVariable("HistDbl", "Double", writable: false, SysTimeSec);
|
||||
var missingNode = rig.EnsureRawVariable("HistMissing", "Double", writable: false, MissingTag);
|
||||
rig.Sink.EnsureFolder(EventSource, parentNodeId: null, displayName: EventSource, realm: AddressSpaceRealm.Raw);
|
||||
rig.Sink.MaterialiseAlarmCondition($"{EventSource}/gate694", EventSource, "gate694", "OffNormalAlarm", 500,
|
||||
AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
NodeId Node(string id) => new(id, ns);
|
||||
|
||||
// ================================================================ #694.3 — native TimeAverage / Total
|
||||
var caps = await AggregateFunctionsAsync(session, ct);
|
||||
report.Add(new Case("694.advertised", null, null, "wire", "Good", caps, null));
|
||||
Check(failures, caps.Split(',').Contains("TimeAverage") && caps.Split(',').Contains("Total"),
|
||||
$"TimeAverage and Total must be advertised by default, got {caps}");
|
||||
|
||||
// The step-held reference: SysTimeSec is an integer tag, which AVEVA integrates as a step (live: [14:00:00, 14:00:20) of
|
||||
// 0..19 answers TimeWeightedAverage 9.5 / Integral 190). Raw rows straddling every case window.
|
||||
var refRows = (await source.ReadRawAsync(SysTimeSec, S0.AddSeconds(-5), S0.AddMinutes(4), 0, ct)).Samples
|
||||
.Where(s => s.Value is not null).Select(s => (T: s.SourceTimestampUtc!.Value, V: Convert.ToDouble(s.Value, CultureInfo.InvariantCulture)))
|
||||
.ToList();
|
||||
Check(failures, refRows.Count >= 240, $"SysTimeSec reference: expected ~1 Hz rows, got {refRows.Count}");
|
||||
double StepIntegral(DateTime a, DateTime b)
|
||||
{
|
||||
double sum = 0;
|
||||
for (var i = 0; i < refRows.Count; i++)
|
||||
{
|
||||
var from = refRows[i].T;
|
||||
var to = i + 1 < refRows.Count ? refRows[i + 1].T : DateTime.MaxValue;
|
||||
var lo = from > a ? from : a;
|
||||
var hi = to < b ? to : b;
|
||||
if (hi > lo) sum += refRows[i].V * (hi - lo).TotalSeconds;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
var nativeCases = new (string Name, DateTime Start, DateTime End, double IntervalMs)[]
|
||||
{
|
||||
("forward.whole", S0, S0.AddMinutes(2), 20_000),
|
||||
("reverse.whole", S0.AddMinutes(2), S0, 20_000),
|
||||
("forward.clipped7s", S0, S0.AddSeconds(127), 20_000),
|
||||
("reverse.clipped7s", S0.AddSeconds(127), S0, 20_000),
|
||||
("forward.subMsClipped", S0, S0.AddSeconds(40).AddTicks(4_000), 20_000), // last interval 0.4 ms at value 40
|
||||
("forward.nonWholeMsClipped", S0, S0.AddSeconds(43).AddTicks(4_000), 20_000), // last interval 3.0004 s
|
||||
("reverse.subMsClipped", S0.AddSeconds(43).AddTicks(4_000), S0.AddSeconds(3), 20_000), // earliest (3, 3.0004] at value 3
|
||||
("forward.fractionalMs", S0.AddSeconds(3).AddTicks(9_970_000), S0.AddSeconds(4).AddTicks(30_000), 1.5), // straddles :04
|
||||
("reverse.fractionalMs", S0.AddSeconds(4).AddTicks(30_000), S0.AddSeconds(3).AddTicks(9_970_000), 1.5),
|
||||
};
|
||||
foreach (var (name, start, end, intervalMs) in nativeCases)
|
||||
{
|
||||
foreach (var (aggName, aggId, aggType) in new[]
|
||||
{
|
||||
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
|
||||
("Total", ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total),
|
||||
})
|
||||
{
|
||||
var w = $"{start:o}/{end:o}@{intervalMs}ms";
|
||||
var wire = await ReadProcessedAsync(session, Node(dblNode), start, end, intervalMs, aggId, ct);
|
||||
var direct = await source.ReadProcessedAsync(SysTimeSec, start, end, TimeSpan.FromTicks((long)(intervalMs * TimeSpan.TicksPerMillisecond)), aggType, ct);
|
||||
report.Add(new Case($"694.{aggName}.{name}", SysTimeSec, w, "wire", wire.Status, null, wire.Rows));
|
||||
CheckSame(failures, $"694.{aggName}.{name}", wire.Rows, Rows(direct.Samples));
|
||||
|
||||
// The independent reference, interval by interval on the Part 13 grid (#677: reverse steps back from StartTime).
|
||||
var expected = Grid(start, end, TimeSpan.FromTicks((long)(intervalMs * TimeSpan.TicksPerMillisecond)));
|
||||
Check(failures, wire.Rows.Count == expected.Count, $"694.{aggName}.{name}: {wire.Rows.Count} buckets vs {expected.Count} intervals");
|
||||
var reference = new List<string>();
|
||||
for (var i = 0; i < Math.Min(wire.Rows.Count, expected.Count); i++)
|
||||
{
|
||||
var (lo, hi, stamp, clipped) = expected[i];
|
||||
var integral = StepIntegral(lo, hi);
|
||||
var want = aggType == HistoryAggregateType.Total ? integral : integral / (hi - lo).TotalSeconds;
|
||||
var row = wire.Rows[i];
|
||||
var got = double.Parse(row.V!, CultureInfo.InvariantCulture);
|
||||
reference.Add($"[{lo:HH:mm:ss.ffffff},{hi:HH:mm:ss.ffffff}) want={want.ToString("R", CultureInfo.InvariantCulture)}");
|
||||
Check(failures, Math.Abs(got - want) <= 1e-9 * Math.Max(1, Math.Abs(want)),
|
||||
$"694.{aggName}.{name}[{i}] [{lo:o},{hi:o}): {got} vs step reference {want}");
|
||||
Check(failures, row.Src == stamp, $"694.{aggName}.{name}[{i}]: stamp {row.Src:o} vs {stamp:o}");
|
||||
Check(failures, row.Status == (clipped ? GoodCalculated | PartialBit : GoodCalculated),
|
||||
$"694.{aggName}.{name}[{i}]: status 0x{row.Status:X8} (clipped={clipped})");
|
||||
}
|
||||
report.Add(new Case($"694.{aggName}.{name}", SysTimeSec, w, "reference", "Good", string.Join("; ", reference), null));
|
||||
}
|
||||
}
|
||||
|
||||
// All-NULL intervals: the float tag's outage (stored NULL at 11:32:36, nothing until 11:38:34).
|
||||
foreach (var (aggName, aggId, aggType) in new[]
|
||||
{
|
||||
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
|
||||
("Total", ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total),
|
||||
})
|
||||
{
|
||||
foreach (var (dir, start, end) in new[] { ("forward", OutageStart, OutageEnd), ("reverse", OutageEnd, OutageStart) })
|
||||
{
|
||||
var w = $"{start:o}/{end:o}@60000ms";
|
||||
var wire = await ReadProcessedAsync(session, Node(floatNode), start, end, 60_000, aggId, ct);
|
||||
var direct = await source.ReadProcessedAsync(FloatTag, start, end, TimeSpan.FromMinutes(1), aggType, ct);
|
||||
report.Add(new Case($"694.{aggName}.allNull.{dir}", FloatTag, w, "wire", wire.Status, null, wire.Rows));
|
||||
CheckSame(failures, $"694.{aggName}.allNull.{dir}", wire.Rows, Rows(direct.Samples));
|
||||
Check(failures, wire.Rows.Count == 9, $"694.{aggName}.allNull.{dir}: {wire.Rows.Count} buckets vs 9");
|
||||
// Interval start minute → expected class. 11:33–11:37 hold no good time at all; 11:32 and 11:38 hold some.
|
||||
foreach (var row in wire.Rows)
|
||||
{
|
||||
var minuteStart = dir == "forward" ? row.Src!.Value : row.Src!.Value.AddMinutes(-1);
|
||||
var m = minuteStart.Minute;
|
||||
var cls = m is >= 33 and <= 37 ? "BadNoData" : m is 32 or 38 ? "Uncertain" : "Good";
|
||||
var ok = cls switch
|
||||
{
|
||||
"BadNoData" => (row.Status & 0xFFFF0000u) == StatusCodes.BadNoData && row.V is null,
|
||||
"Uncertain" => (row.Status & 0xC0000000u) == 0x40000000u && row.V is not null,
|
||||
_ => (row.Status & 0xC0000000u) == 0 && row.V is not null,
|
||||
};
|
||||
Check(failures, ok, $"694.{aggName}.allNull.{dir} {minuteStart:HH:mm}: expected {cls}, got 0x{row.Status:X8} value {row.V ?? "null"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================ #700 — missing / existing tag, DataTypes, AtTime
|
||||
{
|
||||
var (start, end) = (S0, S0.AddSeconds(30));
|
||||
var w = $"{start:o}/{end:o}";
|
||||
var raw = await ReadRawAsync(session, Node(missingNode), start, end, 0, ct);
|
||||
var avg = await ReadProcessedAsync(session, Node(missingNode), start, end, 10_000, ObjectIds.AggregateFunction_Average, ct);
|
||||
var tavg = await ReadProcessedAsync(session, Node(missingNode), start, end, 10_000, ObjectIds.AggregateFunction_TimeAverage, ct);
|
||||
var at = await ReadAtTimeAsync(session, Node(missingNode), [start, start.AddSeconds(1.5)], ct);
|
||||
report.Add(new Case("700.missing.raw", MissingTag, w, "wire", raw.Status, null, raw.Rows));
|
||||
report.Add(new Case("700.missing.average", MissingTag, w, "wire", avg.Status, null, avg.Rows));
|
||||
report.Add(new Case("700.missing.timeAverage", MissingTag, w, "wire", tavg.Status, null, tavg.Rows));
|
||||
report.Add(new Case("700.missing.atTime", MissingTag, w, "wire", at.Status, null, at.Rows));
|
||||
Check(failures, raw.Status == "GoodNoData" && raw.Rows.Count == 0, $"700.missing.raw: {raw.Status} rows={raw.Rows.Count}");
|
||||
foreach (var (n, r) in new[] { ("average", avg), ("timeAverage", tavg) })
|
||||
{
|
||||
Check(failures, r.Rows.Count == 3, $"700.missing.{n}: {r.Rows.Count} buckets vs 3");
|
||||
Check(failures, r.Rows.All(x => (x.Status & 0xFFFF0000u) == StatusCodes.BadNoData && x.V is null),
|
||||
$"700.missing.{n}: every bucket must be BadNoData, got {string.Join(",", r.Rows.Select(x => $"0x{x.Status:X8}"))}");
|
||||
}
|
||||
Check(failures, at.Rows.Count == 2 && at.Rows.All(x => StatusCode.IsBad(x.Status)),
|
||||
$"700.missing.atTime: expected two Bad values, got {at.Status} {string.Join(",", at.Rows.Select(x => $"0x{x.Status:X8}"))}");
|
||||
}
|
||||
|
||||
foreach (var (label, node, tag, type, typeCode) in new[]
|
||||
{
|
||||
("int32", intNode, SysTimeSec, "Int32", TypeCode.Int32),
|
||||
("boolean", boolNode, SysPulse, "Boolean", TypeCode.Boolean),
|
||||
("float", floatNode, FloatTag, "Single", TypeCode.Single),
|
||||
})
|
||||
{
|
||||
var (start, end) = label == "float"
|
||||
? (Utc("2026-11-10T11:31:00Z"), Utc("2026-11-10T11:31:20Z"))
|
||||
: label == "boolean" ? (S0, S0.AddMinutes(5)) : (S0, S0.AddSeconds(10));
|
||||
var w = $"{start:o}/{end:o}";
|
||||
var raw = await ReadRawAsync(session, Node(node), start, end, 0, ct);
|
||||
var direct = await source.ReadRawAsync(tag, start, end, 0, ct);
|
||||
report.Add(new Case($"700.existing.raw.{label}", tag, w, "wire", raw.Status, Types(raw.Rows), raw.Rows));
|
||||
Check(failures, raw.Rows.Count > 0 && raw.Rows.Count == direct.Samples.Count,
|
||||
$"700.existing.raw.{label}: wire {raw.Rows.Count} vs direct {direct.Samples.Count}");
|
||||
Check(failures, raw.Rows.All(r => r.Type == type), $"700.existing.raw.{label}: value types {Types(raw.Rows)}, node DataType {type}");
|
||||
for (var i = 0; i < Math.Min(raw.Rows.Count, direct.Samples.Count); i++)
|
||||
Check(failures, raw.Rows[i].Src == direct.Samples[i].SourceTimestampUtc && raw.Rows[i].Status == direct.Samples[i].StatusCode,
|
||||
$"700.existing.raw.{label}[{i}]: wire ({raw.Rows[i].Src:o}, 0x{raw.Rows[i].Status:X8}) vs direct");
|
||||
|
||||
// AtTime: a stored timestamp and a midpoint, typed as the node.
|
||||
var times = new List<DateTime> { raw.Rows[0].Src!.Value };
|
||||
if (raw.Rows.Count > 1) times.Add(raw.Rows[0].Src!.Value + (raw.Rows[1].Src!.Value - raw.Rows[0].Src!.Value) / 2);
|
||||
var at = await ReadAtTimeAsync(session, Node(node), times, ct);
|
||||
var atDirect = await source.ReadAtTimeAsync(tag, times, typeCode, ct);
|
||||
report.Add(new Case($"700.existing.atTime.{label}", tag, string.Join(",", times.Select(t => t.ToString("o"))), "wire", at.Status,
|
||||
Types(at.Rows), at.Rows));
|
||||
CheckSame(failures, $"700.existing.atTime.{label}", at.Rows, Rows(atDirect.Samples));
|
||||
Check(failures, at.Rows.Select(r => r.Src).SequenceEqual(times.Select(t => (DateTime?)t)),
|
||||
$"700.existing.atTime.{label}: timestamps must be the requested ones, in order");
|
||||
Check(failures, at.Rows.All(r => r.V is null || r.Type == type), $"700.existing.atTime.{label}: types {Types(at.Rows)} vs {type}");
|
||||
|
||||
// Processed: a per-sample and a native aggregate over the same window, wire vs the backend.
|
||||
foreach (var (aggName, aggId, aggType) in new[]
|
||||
{
|
||||
("Average", ObjectIds.AggregateFunction_Average, HistoryAggregateType.Average),
|
||||
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
|
||||
})
|
||||
{
|
||||
var intervalMs = (end - start).TotalMilliseconds / 2;
|
||||
var p = await ReadProcessedAsync(session, Node(node), start, end, intervalMs, aggId, ct);
|
||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult? d = null;
|
||||
string? directError = null;
|
||||
try { d = await source.ReadProcessedAsync(tag, start, end, TimeSpan.FromMilliseconds(intervalMs), aggType, ct); }
|
||||
catch (Exception ex) { directError = $"{ex.GetType().Name}: {ex.Message}"; }
|
||||
report.Add(new Case($"700.existing.processed.{aggName}.{label}", tag, w, "wire", p.Status,
|
||||
$"{Types(p.Rows)} direct={directError ?? "ok"}", p.Rows));
|
||||
if (label == "boolean" && aggType == HistoryAggregateType.TimeAverage)
|
||||
{
|
||||
// AVEVA refuses its native time-weighted modes on a discrete tag (StartQuery fails): recorded; the node
|
||||
// must answer Bad, never a value.
|
||||
Check(failures, p.Status.StartsWith("Bad", StringComparison.Ordinal) && p.Rows.Count == 0,
|
||||
$"700.existing.processed.TimeAverage.boolean: {p.Status} rows={p.Rows.Count} (direct: {directError ?? "served"})");
|
||||
continue;
|
||||
}
|
||||
if (d is null) { failures.Add($"700.existing.processed.{aggName}.{label}: direct read failed: {directError}"); continue; }
|
||||
CheckSame(failures, $"700.existing.processed.{aggName}.{label}", p.Rows, Rows(d.Samples));
|
||||
Check(failures, p.Rows.Count == 2, $"700.existing.processed.{aggName}.{label}: {p.Rows.Count} buckets");
|
||||
}
|
||||
}
|
||||
|
||||
// Future AtTime: requested timestamps and order preserved; cost recorded.
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var times = new[] { now.AddHours(1), now.AddMinutes(30), now.AddHours(2) };
|
||||
var sw = Stopwatch.StartNew();
|
||||
var at = await ReadAtTimeAsync(session, Node(intNode), times, ct);
|
||||
sw.Stop();
|
||||
report.Add(new Case("700.atTime.future", SysTimeSec, string.Join(",", times.Select(t => t.ToString("o"))), "wire", at.Status,
|
||||
$"elapsedMs={sw.ElapsedMilliseconds} {Types(at.Rows)}", at.Rows));
|
||||
Check(failures, at.Rows.Select(r => r.Src).SequenceEqual(times.Select(t => (DateTime?)t)),
|
||||
"700.atTime.future: timestamps must be the requested ones, in request order");
|
||||
Check(failures, at.Rows.All(r => !StatusCode.IsGood(r.Status)), "700.atTime.future: a future value is never Good");
|
||||
}
|
||||
|
||||
// ================================================================ #746.1 — events paging, server-side max_events
|
||||
{
|
||||
// A second production data source whose EventDrainBudget is 0: without the server cap every truncated page would
|
||||
// abandon (cancel) its gateway stream at the cap.
|
||||
var budget0 = Options(endpoint!, apiKey!);
|
||||
var eventsOptions = new ServerHistorianOptions
|
||||
{
|
||||
Backend = budget0.Backend, Enabled = true, Endpoint = budget0.Endpoint, ApiKey = budget0.ApiKey, UseTls = budget0.UseTls,
|
||||
AllowUntrustedServerCertificate = budget0.AllowUntrustedServerCertificate,
|
||||
Gateway = new GatewayHistorianSettings { EventDrainBudget = 0 },
|
||||
};
|
||||
using var eventsSource = HistorianBackendRegistry.Resolve(eventsOptions, new ConfigurationBuilder().Build())
|
||||
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
|
||||
rig.Nm.HistorianDataSource = eventsSource;
|
||||
var (start, end) = EventWindow;
|
||||
var w = $"{start:o}/{end:o}";
|
||||
var whole = await ReadEventsAsync(session, Node(EventSource), start, end, 0, ct);
|
||||
var sw = Stopwatch.StartNew();
|
||||
var paged1 = await ReadEventsAsync(session, Node(EventSource), start, end, 1, ct);
|
||||
var paged7 = await ReadEventsAsync(session, Node(EventSource), start, end, 7, ct);
|
||||
var reverse5 = await ReadEventsAsync(session, Node(EventSource), end, start, 5, ct);
|
||||
sw.Stop();
|
||||
var abandoned = Internal<long>(eventsSource, "EventStreamsAbandoned");
|
||||
var reReads = Internal<long>(eventsSource, "EventServerCapReReads");
|
||||
report.Add(new Case("746.events.whole", EventSource, w, "wire", whole.Status, $"events={whole.Rows.Count}", null, null, whole.Rows));
|
||||
report.Add(new Case("746.events.paged1", EventSource, w, "wire", paged1.Status, $"pages={paged1.Pages}", null, null, paged1.Rows));
|
||||
report.Add(new Case("746.events.paged7", EventSource, w, "wire", paged7.Status, $"pages={paged7.Pages}", null, null, paged7.Rows));
|
||||
report.Add(new Case("746.events.reverse5", EventSource, w, "wire", reverse5.Status, $"pages={reverse5.Pages}", null, null, reverse5.Rows));
|
||||
report.Add(new Case("746.events.streams", EventSource, w, "direct", "Good",
|
||||
$"abandonedStreams={abandoned} serverCapReReads={reReads} pagedElapsedMs={sw.ElapsedMilliseconds}", null));
|
||||
Check(failures, whole.Rows.Count > 7, $"746.events: expected more than one 7-event page, got {whole.Rows.Count}");
|
||||
var ids = whole.Rows.Select(r => r.Id).ToList();
|
||||
Check(failures, paged1.Rows.Select(r => r.Id).SequenceEqual(ids), "746.events: 1-event pages must equal the whole read");
|
||||
Check(failures, paged7.Rows.Select(r => r.Id).SequenceEqual(ids), "746.events: 7-event pages must equal the whole read");
|
||||
var reverseIds = whole.Rows.OrderByDescending(r => r.Time).ThenByDescending(r => r.Id, StringComparer.Ordinal).Select(r => r.Id);
|
||||
Check(failures, reverse5.Rows.Select(r => r.Id).SequenceEqual(reverseIds), "746.events: 5-event reverse pages, newest first");
|
||||
Check(failures, abandoned == 0, $"746.events: {abandoned} gateway streams abandoned with EventDrainBudget=0 — max_events not honoured");
|
||||
rig.Nm.HistorianDataSource = source;
|
||||
}
|
||||
|
||||
// ================================================================ #700 — transport outage vs unsupported
|
||||
{
|
||||
var dead = new ServerHistorianOptions
|
||||
{
|
||||
Backend = "Gateway", Enabled = true, Endpoint = "http://127.0.0.1:9", ApiKey = apiKey!, UseTls = false,
|
||||
};
|
||||
using var deadSource = HistorianBackendRegistry.Resolve(dead, new ConfigurationBuilder().Build())
|
||||
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
|
||||
rig.Nm.HistorianDataSource = deadSource;
|
||||
var (start, end) = (S0, S0.AddSeconds(30));
|
||||
var w = $"{start:o}/{end:o}";
|
||||
var results = new Dictionary<string, string>
|
||||
{
|
||||
["raw"] = (await ReadRawAsync(session, Node(intNode), start, end, 0, ct)).Status,
|
||||
["processed.Average"] = (await ReadProcessedAsync(session, Node(intNode), start, end, 10_000, ObjectIds.AggregateFunction_Average, ct)).Status,
|
||||
["processed.TimeAverage"] = (await ReadProcessedAsync(session, Node(intNode), start, end, 10_000, ObjectIds.AggregateFunction_TimeAverage, ct)).Status,
|
||||
["atTime"] = (await ReadAtTimeAsync(session, Node(intNode), [start], ct)).Status,
|
||||
["events"] = (await ReadEventsAsync(session, Node(EventSource), EventWindow.Start, EventWindow.End, 0, ct)).Status,
|
||||
["modified"] = await ReadModifiedAsync(session, Node(intNode), start, end, ct),
|
||||
["processed.TimeAverage2"] = (await ReadProcessedAsync(session, Node(intNode), start, end, 10_000, ObjectIds.AggregateFunction_TimeAverage2, ct)).Status,
|
||||
};
|
||||
report.Add(new Case("700.outage", SysTimeSec, w, "wire", "see note",
|
||||
string.Join(" ", results.Select(kv => $"{kv.Key}={kv.Value}")), null));
|
||||
foreach (var k in new[] { "raw", "processed.Average", "processed.TimeAverage", "atTime", "events" })
|
||||
Check(failures, results[k] == "BadNoCommunication", $"700.outage.{k}: {results[k]} (expected BadNoCommunication)");
|
||||
Check(failures, results["modified"] == "BadHistoryOperationUnsupported", $"700.outage.modified: {results["modified"]}");
|
||||
Check(failures, results["processed.TimeAverage2"] == "BadAggregateNotSupported", $"700.outage.TimeAverage2: {results["processed.TimeAverage2"]}");
|
||||
|
||||
// Positive control: the same session, back on the live source, reads again.
|
||||
rig.Nm.HistorianDataSource = source;
|
||||
var back = await ReadRawAsync(session, Node(intNode), start, end, 0, ct);
|
||||
report.Add(new Case("700.outage.recovered", SysTimeSec, w, "wire", back.Status, $"rows={back.Rows.Count}", null));
|
||||
Check(failures, back.Rows.Count > 0 && !back.Status.StartsWith("Bad", StringComparison.Ordinal), $"700.outage.recovered: {back.Status}");
|
||||
}
|
||||
|
||||
await WriteReportAsync(report, failures, "followups", ct);
|
||||
failures.ShouldBeEmpty();
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #700, controlled fixture: a strict gateway (<c>AllowStreamingBeyondCeiling=false</c>) whose ceiling the test changes
|
||||
/// by restarting it. Checks the strict-ceiling stopped-tag reverse read (order, ties, completeness, timestamps; cost in
|
||||
/// gateway calls against #696's unit bound) and that one long-lived data source follows a lowered and a raised
|
||||
/// ceiling, including the per-sample aggregate's 30 s cooldown.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Strict_ceiling_fixture_stopped_tag_reverse_cost_and_raised_lowered_limits()
|
||||
{
|
||||
var endpoint = Env("OTOPCUA_AVEVA_GATE_STRICT_ENDPOINT");
|
||||
var setCeiling = Env("OTOPCUA_AVEVA_GATE_STRICT_SET_CEILING");
|
||||
var apiKey = Env("HISTGW_GATEWAY_APIKEY");
|
||||
var referenceEndpoint = Env("HISTGW_GATEWAY_ENDPOINT");
|
||||
if (endpoint is null || setCeiling is null || apiKey is null || referenceEndpoint is null)
|
||||
Assert.Skip("Set OTOPCUA_AVEVA_GATE_STRICT_ENDPOINT, OTOPCUA_AVEVA_GATE_STRICT_SET_CEILING, HISTGW_GATEWAY_ENDPOINT (a " +
|
||||
"streaming gateway for the reference reads) and HISTGW_GATEWAY_APIKEY to run the strict-ceiling fixture.");
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var report = new Report(Env("OTOPCUA_AVEVA_GATE_LABEL") ?? "unlabelled", endpoint!);
|
||||
var failures = new List<string>();
|
||||
var calls = new GatewayCallCounter(Env("OTOPCUA_AVEVA_GATE_STRICT_CONTAINER"));
|
||||
|
||||
await SetCeilingAsync(setCeiling!, 1000, ct);
|
||||
var options = Options(endpoint!, apiKey!);
|
||||
using var source = HistorianBackendRegistry.Resolve(options, new ConfigurationBuilder().Build())
|
||||
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
|
||||
// References come from a streaming gateway in front of the same historian (the strict one would refuse them).
|
||||
using var reference = HistorianBackendRegistry.Resolve(Options(referenceEndpoint!, apiKey!), new ConfigurationBuilder().Build())
|
||||
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
await using var rig = await StartAsync("AvevaGate700Strict", encrypted: false, ct);
|
||||
rig.Nm.HistorianDataSource = source;
|
||||
rig.Nm.MaxTieClusterOverfetch = options.MaxTieClusterOverfetch;
|
||||
rig.Nm.HonorReturnBounds = options.HonorReturnBounds;
|
||||
rig.Nm.HistoryReadDeadline = options.HistoryReadDeadline;
|
||||
rig.Enforce(false, [Key()]);
|
||||
var floatNode = rig.EnsureRawVariable("HistFloat", "Float", writable: false, FloatTag);
|
||||
var dblNode = rig.EnsureRawVariable("HistSec", "Double", writable: false, SysTimeSec);
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
NodeId Node(string id) => new(id, ns);
|
||||
|
||||
// ---- Stopped-tag reverse read: the float tag stopped at 11:32:36 (NULL) and resumed at 11:38:34. Read the latest 3
|
||||
// before 11:38:00 (end-only: StartTime unset, EndTime 11:38:00 — Part 11 reads back from EndTime), on the strict
|
||||
// 1,000-row gateway.
|
||||
{
|
||||
var at = Utc("2026-11-10T11:38:00Z");
|
||||
var stored = (await reference.ReadRawAsync(FloatTag, at.AddHours(-1), at, 0, ct)).Samples;
|
||||
var expected = stored.Reverse().Take(3).Select(s => s.SourceTimestampUtc).ToList();
|
||||
await calls.MarkAsync("ReadRaw", ct);
|
||||
var sw = Stopwatch.StartNew();
|
||||
var r = await ReadRawAsync(session, Node(floatNode), DateTime.MinValue, at, 3, ct);
|
||||
sw.Stop();
|
||||
var n = await calls.CountAsync("ReadRaw", ct);
|
||||
report.Add(new Case("700.strict.stoppedTagReverse3", FloatTag, $"(end-only)/{at:o}", "wire", r.Status,
|
||||
$"gatewayReadRawCalls={n} elapsedMs={sw.ElapsedMilliseconds} unitBound=12 (#696: 7,200 stopped 1 Hz rows, ceiling 1,000, one day later)",
|
||||
r.Rows));
|
||||
Check(failures, r.Rows.Select(x => x.Src).SequenceEqual(expected),
|
||||
$"700.strict.stoppedTagReverse3: {string.Join(",", r.Rows.Select(x => x.Src?.ToString("o")))} vs {string.Join(",", expected.Select(x => x?.ToString("o")))}");
|
||||
Check(failures, n is > 0 and <= 12, $"700.strict.stoppedTagReverse3: {n} gateway ReadRaw calls (unit bound 12)");
|
||||
|
||||
// A reverse read across the whole outage: newest first, paged 37, equal to the forward reference reversed.
|
||||
var (s0, s1) = (Utc("2026-11-10T11:30:00Z"), Utc("2026-11-10T11:41:00Z"));
|
||||
var fwd = (await reference.ReadRawAsync(FloatTag, s0.AddTicks(1), s1.AddTicks(1), 0, ct)).Samples;
|
||||
var paged = await ReadRawPagedAsync(session, Node(floatNode), s1, s0, 37, ct);
|
||||
report.Add(new Case("700.strict.reversePaged37", FloatTag, $"{s1:o}/{s0:o}", "wire", paged.Status, $"pages={paged.Pages}", paged.Rows));
|
||||
CheckSame(failures, "700.strict.reversePaged37", paged.Rows, Rows(fwd).AsEnumerable().Reverse().ToList());
|
||||
}
|
||||
|
||||
// ---- Lowered then raised limits, one long-lived data source. A 30-minute SysTimeSec raw read (1,800 rows) pages at
|
||||
// the learned ceiling; an Average over it (one 30-minute interval) needs more rows than a 1,000 / 500 ceiling allows.
|
||||
// The Average goes FIRST in each probe: a raw read that finds the gateway raised forgets the learned ceiling, which
|
||||
// would end the per-sample aggregate's cooldown early.
|
||||
var (a, b) = (S0, S0.AddMinutes(30));
|
||||
async Task<(WireRead Avg, WireRead Raw)> Probe(string phase)
|
||||
{
|
||||
var avg = await ReadProcessedAsync(session, Node(dblNode), a, b, 30 * 60_000, ObjectIds.AggregateFunction_Average, ct);
|
||||
var raw = await ReadRawPagedAsync(session, Node(dblNode), a, b, 0, ct);
|
||||
report.Add(new Case($"700.limits.{phase}.average", SysTimeSec, $"{a:o}/{b:o}", "wire", avg.Status,
|
||||
string.Join(",", avg.Rows.Select(r => $"0x{r.Status:X8}")), avg.Rows));
|
||||
report.Add(new Case($"700.limits.{phase}.raw", SysTimeSec, $"{a:o}/{b:o}", "wire", raw.Status, $"pages={raw.Pages} rows={raw.Rows.Count}", null));
|
||||
return (avg, raw);
|
||||
}
|
||||
static bool Refused(WireRead r) =>
|
||||
r.Status == "BadTooManyOperations" || (r.Rows.Count == 1 && r.Rows[0].Status == StatusCodes.BadTooManyOperations);
|
||||
|
||||
var at1000 = await Probe("ceiling1000");
|
||||
Check(failures, at1000.Raw.Rows.Count == 1800 && at1000.Raw.Pages >= 2, $"700.limits.1000: raw {at1000.Raw.Rows.Count} rows / {at1000.Raw.Pages} pages");
|
||||
Check(failures, Refused(at1000.Avg), $"700.limits.1000: an Average over 1,800 rows on a 1,000-row strict gateway must be BadTooManyOperations, got {at1000.Avg.Status}");
|
||||
|
||||
await SetCeilingAsync(setCeiling!, 500, ct); // lowered: learned at once, from the next refusal
|
||||
var lastRefusal = DateTime.UtcNow;
|
||||
var at500 = await Probe("ceiling500");
|
||||
Check(failures, Refused(at500.Avg), $"700.limits.500: Average must be BadTooManyOperations, got {at500.Avg.Status}");
|
||||
Check(failures, at500.Raw.Rows.Count == 1800 && at500.Raw.Pages > at1000.Raw.Pages, $"700.limits.500: raw {at500.Raw.Rows.Count} rows / {at500.Raw.Pages} pages");
|
||||
|
||||
await SetCeilingAsync(setCeiling!, 5000, ct); // raised: the per-sample aggregate reuses the 500 refusal for 30 s
|
||||
var sinceRefusal = DateTime.UtcNow - lastRefusal;
|
||||
var inCooldown = await ReadProcessedAsync(session, Node(dblNode), a, b, 30 * 60_000, ObjectIds.AggregateFunction_Average, ct);
|
||||
report.Add(new Case("700.limits.ceiling5000.averageInCooldown", SysTimeSec, $"{a:o}/{b:o}", "wire", inCooldown.Status,
|
||||
$"secondsSinceLastRefusal={sinceRefusal.TotalSeconds:F1} {string.Join(",", inCooldown.Rows.Select(r => $"0x{r.Status:X8}"))}", inCooldown.Rows));
|
||||
if (sinceRefusal < TimeSpan.FromSeconds(25))
|
||||
Check(failures, Refused(inCooldown), $"700.limits.5000: inside the 30 s cooldown the Average stays a request limit, got {inCooldown.Status}");
|
||||
var wait = lastRefusal.AddSeconds(31) - DateTime.UtcNow;
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait, ct);
|
||||
var after = await Probe("ceiling5000.afterCooldown");
|
||||
Check(failures, after.Avg.Rows.Count == 1 && StatusCode.IsGood(after.Avg.Rows[0].Status),
|
||||
$"700.limits.5000: after the cooldown the Average is served, got {after.Avg.Status} {string.Join(",", after.Avg.Rows.Select(r => $"0x{r.Status:X8}"))}");
|
||||
Check(failures, after.Raw.Rows.Count == 1800 && after.Raw.Pages == 1, $"700.limits.5000: raw {after.Raw.Rows.Count} rows / {after.Raw.Pages} pages");
|
||||
|
||||
await SetCeilingAsync(setCeiling!, 1000, ct);
|
||||
await WriteReportAsync(report, failures, "strict", ct);
|
||||
failures.ShouldBeEmpty();
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ fixture helpers
|
||||
|
||||
private static ServerHistorianOptions Options(string endpoint, string apiKey) => new()
|
||||
{
|
||||
Backend = "Gateway",
|
||||
Enabled = true,
|
||||
Endpoint = endpoint,
|
||||
ApiKey = apiKey,
|
||||
UseTls = endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
|
||||
AllowUntrustedServerCertificate = string.Equals(Env("HISTGW_GATEWAY_ALLOW_UNTRUSTED"), "true", StringComparison.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
private static async Task SetCeilingAsync(string script, int ceiling, CancellationToken ct)
|
||||
{
|
||||
using var p = Process.Start(new ProcessStartInfo("bash", [script, ceiling.ToString(CultureInfo.InvariantCulture)])
|
||||
{
|
||||
RedirectStandardOutput = true, RedirectStandardError = true,
|
||||
})!;
|
||||
await p.WaitForExitAsync(ct);
|
||||
p.ExitCode.ShouldBe(0, $"{script} {ceiling}: {await p.StandardError.ReadToEndAsync(ct)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts a gateway container's gRPC calls by method from its request log: the calls since the last
|
||||
/// <see cref="MarkAsync"/> (a delta of whole-log counts, so the Docker VM's clock does not matter).
|
||||
/// </summary>
|
||||
private sealed class GatewayCallCounter(string? container)
|
||||
{
|
||||
private readonly Dictionary<string, int> _marks = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task MarkAsync(string method, CancellationToken ct) => _marks[method] = await TotalAsync(method, ct);
|
||||
|
||||
public async Task<int> CountAsync(string method, CancellationToken ct)
|
||||
{
|
||||
if (container is null) return -1;
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct); // let the container's request log reach `docker logs`
|
||||
return await TotalAsync(method, ct) - _marks.GetValueOrDefault(method);
|
||||
}
|
||||
|
||||
private async Task<int> TotalAsync(string method, CancellationToken ct)
|
||||
{
|
||||
if (container is null) return 0;
|
||||
using var p = Process.Start(new ProcessStartInfo("docker", ["logs", container])
|
||||
{
|
||||
RedirectStandardOutput = true, RedirectStandardError = true,
|
||||
})!;
|
||||
var stdout = p.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderr = p.StandardError.ReadToEndAsync(ct);
|
||||
await p.WaitForExitAsync(ct);
|
||||
var text = await stdout + await stderr;
|
||||
return text.Split('\n').Count(l => l.Contains("Request starting", StringComparison.Ordinal)
|
||||
&& l.Contains($".HistorianRead/{method} ", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
private static T Internal<T>(object instance, string property) =>
|
||||
(T)instance.GetType().GetProperty(property, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
|
||||
.GetValue(instance)!;
|
||||
|
||||
/// <summary>
|
||||
/// The Part 13 interval grid (#677): forward — from StartTime, the clipped interval the latest, each stamped with its
|
||||
/// earlier edge; reverse — stepping back from StartTime, the clipped interval the earliest, stamped with its later edge,
|
||||
/// newest first. Each entry is (lo, hi, stamp, clipped).
|
||||
/// </summary>
|
||||
private static List<(DateTime Lo, DateTime Hi, DateTime Stamp, bool Clipped)> Grid(DateTime start, DateTime end, TimeSpan interval)
|
||||
{
|
||||
var list = new List<(DateTime, DateTime, DateTime, bool)>();
|
||||
if (start <= end)
|
||||
{
|
||||
for (var lo = start; lo < end; lo += interval)
|
||||
{
|
||||
var hi = lo + interval > end ? end : lo + interval;
|
||||
list.Add((lo, hi, lo, hi - lo < interval));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var hi = start; hi > end; hi -= interval)
|
||||
{
|
||||
var lo = hi - interval < end ? end : hi - interval;
|
||||
list.Add((lo, hi, hi, hi - lo < interval));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static async Task WriteReportAsync(Report report, List<string> failures, string kind, CancellationToken ct)
|
||||
{
|
||||
var outDir = Env("OTOPCUA_AVEVA_GATE_OUT") ?? Path.GetTempPath();
|
||||
Directory.CreateDirectory(outDir);
|
||||
var file = Path.Combine(outDir, $"aveva-gate-{kind}-{Sanitize(report.Label)}.json");
|
||||
report.Failures = failures;
|
||||
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), ct);
|
||||
TestContext.Current.TestOutputHelper?.WriteLine($"AVEVA gate report: {file} ({report.Cases.Count} cases, {failures.Count} failures)");
|
||||
foreach (var f in failures) TestContext.Current.TestOutputHelper?.WriteLine($"FAIL {f}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ wire helpers
|
||||
|
||||
private static async Task<WireRead> ReadRawAsync(ISession session, NodeId node, DateTime start, DateTime end, uint max, CancellationToken ct)
|
||||
{
|
||||
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = false, ReturnBounds = false };
|
||||
var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
||||
await ReleaseAsync(session, details, node, cp, ct);
|
||||
return new WireRead(status, rows, 1);
|
||||
}
|
||||
|
||||
private static async Task<WireRead> ReadRawPagedAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page, CancellationToken ct)
|
||||
{
|
||||
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, IsReadModified = false, ReturnBounds = false };
|
||||
return await PageAsync(session, details, node, ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadModifiedAsync(ISession session, NodeId node, DateTime start, DateTime end, CancellationToken ct)
|
||||
{
|
||||
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = 0, IsReadModified = true, ReturnBounds = false };
|
||||
var (status, _, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
||||
await ReleaseAsync(session, details, node, cp, ct);
|
||||
return status;
|
||||
}
|
||||
|
||||
private static async Task<WireRead> ReadProcessedAsync(ISession session, NodeId node, DateTime start, DateTime end, double intervalMs,
|
||||
NodeId aggregate, CancellationToken ct)
|
||||
{
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = start, EndTime = end, ProcessingInterval = intervalMs,
|
||||
AggregateType = new List<NodeId> { aggregate },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
};
|
||||
return await PageAsync(session, details, node, ct);
|
||||
}
|
||||
|
||||
private static async Task<WireRead> PageAsync(ISession session, IEncodeable details, NodeId node, CancellationToken ct)
|
||||
{
|
||||
var all = new List<Row>();
|
||||
var pages = 0;
|
||||
ByteString cp = default;
|
||||
string status;
|
||||
do
|
||||
{
|
||||
(status, var rows, cp) = await HistoryReadAsync(session, details, node, cp, ct);
|
||||
all.AddRange(rows);
|
||||
pages++;
|
||||
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
||||
}
|
||||
while (!cp.IsEmpty && pages < 10_000);
|
||||
return new WireRead(status, all, pages);
|
||||
}
|
||||
|
||||
private static async Task<WireRead> ReadAtTimeAsync(ISession session, NodeId node, IReadOnlyList<DateTime> times, CancellationToken ct)
|
||||
{
|
||||
var details = new ReadAtTimeDetails { ReqTimes = times.Select(t => (DateTimeUtc)t).ToList(), UseSimpleBounds = true };
|
||||
var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
||||
await ReleaseAsync(session, details, node, cp, ct);
|
||||
return new WireRead(status, rows, 1);
|
||||
}
|
||||
|
||||
private static async Task<(string Status, List<Row> Rows, ByteString Cp)> HistoryReadAsync(
|
||||
ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
|
||||
{
|
||||
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
||||
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
var result = response.Results.ToList().ShouldHaveSingleItem();
|
||||
var rows = new List<Row>();
|
||||
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryData data)
|
||||
rows.AddRange(data.DataValues.ToList().Select(dv =>
|
||||
{
|
||||
var v = dv.ClrValue();
|
||||
return new Row(Fmt(v), dv.StatusCode.Code, Ts(dv.SourceTimestamp), v?.GetType().Name);
|
||||
}));
|
||||
return (Name(result.StatusCode), rows, result.ContinuationPoint);
|
||||
}
|
||||
|
||||
private static async Task ReleaseAsync(ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
|
||||
{
|
||||
if (cp.IsEmpty) return;
|
||||
await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
|
||||
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
}
|
||||
|
||||
private static async Task<EventRead> ReadEventsAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page, CancellationToken ct)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
foreach (var field in new[] { "EventId", "Time", "SourceName", "Severity" })
|
||||
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(field)) { AttributeId = Attributes.Value };
|
||||
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, Filter = filter };
|
||||
var rows = new List<EventRow>();
|
||||
var pages = 0;
|
||||
ByteString cp = default;
|
||||
string status;
|
||||
do
|
||||
{
|
||||
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
||||
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
var result = response.Results.ToList().ShouldHaveSingleItem();
|
||||
status = Name(result.StatusCode);
|
||||
cp = result.ContinuationPoint;
|
||||
pages++;
|
||||
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he)
|
||||
foreach (var e in he.Events.ToList())
|
||||
{
|
||||
var f = e.EventFields.ToList();
|
||||
rows.Add(new EventRow(
|
||||
f[0].ClrValue() is byte[] id ? System.Text.Encoding.UTF8.GetString(id) : Fmt(f[0].ClrValue()),
|
||||
f[1].ClrValue() as DateTime? ?? default, f[2].ClrValue() as string,
|
||||
Convert.ToUInt16(f[3].ClrValue() ?? 0, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
||||
}
|
||||
while (!cp.IsEmpty && pages < 10_000);
|
||||
return new EventRead(status, rows, pages);
|
||||
}
|
||||
|
||||
private static async Task<string> AggregateFunctionsAsync(ISession session, CancellationToken ct)
|
||||
{
|
||||
var response = await session.BrowseAsync(null, null, 0,
|
||||
new BrowseDescription[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = ObjectIds.HistoryServerCapabilities_AggregateFunctions, BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.Organizes, IncludeSubtypes = true, NodeClassMask = 0,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
}, ct);
|
||||
return string.Join(",", response.Results[0].References.ToList().Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static DateTime? Ts(DateTimeUtc t) => (DateTime)t == DateTime.MinValue ? null : DateTime.SpecifyKind((DateTime)t, DateTimeKind.Utc);
|
||||
|
||||
private static string Name(StatusCode code) => code.SymbolicId is { Length: > 0 } s ? s : $"0x{code.Code:X8}";
|
||||
|
||||
private static List<Row> Rows(IReadOnlyList<DataValueSnapshot> samples) =>
|
||||
samples.Select(s => new Row(Fmt(s.Value), s.StatusCode, s.SourceTimestampUtc, s.Value?.GetType().Name)).ToList();
|
||||
|
||||
private static string Types(IEnumerable<Row> rows) =>
|
||||
"types=" + string.Join("/", rows.Select(r => r.Type ?? "null").Distinct(StringComparer.Ordinal));
|
||||
|
||||
private static string? Fmt(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
double d => d.ToString("R", CultureInfo.InvariantCulture),
|
||||
float f => ((double)f).ToString("R", CultureInfo.InvariantCulture),
|
||||
bool b => b ? "true" : "false",
|
||||
DateTime t => t.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
|
||||
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>Wire vs reference: value text, status and source timestamp (types may differ: the backend answers doubles).</summary>
|
||||
private static void CheckSame(List<string> failures, string what, IReadOnlyList<Row> wire, IReadOnlyList<Row> reference)
|
||||
{
|
||||
if (wire.Count != reference.Count)
|
||||
{
|
||||
failures.Add($"{what}: wire {wire.Count} rows vs reference {reference.Count}");
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < wire.Count; i++)
|
||||
{
|
||||
var (x, y) = (wire[i], reference[i]);
|
||||
if (!SameValue(x.V, y.V) || x.Status != y.Status || x.Src != y.Src)
|
||||
{
|
||||
failures.Add($"{what}[{i}]: wire ({x.V}, 0x{x.Status:X8}, {x.Src:o}) vs reference ({y.V}, 0x{y.Status:X8}, {y.Src:o})");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameValue(string? a, string? b)
|
||||
{
|
||||
if (a == b) return true;
|
||||
if (a is null || b is null) return false;
|
||||
if (double.TryParse(a, NumberStyles.Float, CultureInfo.InvariantCulture, out var x)
|
||||
&& double.TryParse(b, NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||||
return x == y || Math.Abs(x - y) <= 1e-6 * Math.Max(1, Math.Abs(y)); // a Float node narrows the backend's double
|
||||
return (a, b) is ("true", "1") or ("false", "0") or ("1", "true") or ("0", "false");
|
||||
}
|
||||
|
||||
private static void Check(List<string> failures, bool ok, string message)
|
||||
{
|
||||
if (!ok) failures.Add(message);
|
||||
}
|
||||
|
||||
private static DateTime Utc(string s) =>
|
||||
DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
|
||||
private static string? Env(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v.Trim() : null;
|
||||
|
||||
private static string Sanitize(string s) => string.Concat(s.Select(c => char.IsLetterOrDigit(c) || c is '.' or '-' ? c : '_'));
|
||||
|
||||
private sealed record Row(string? V, uint Status, DateTime? Src, string? Type);
|
||||
|
||||
private sealed record EventRow(string? Id, DateTime Time, string? Source, ushort Severity);
|
||||
|
||||
private sealed record WireRead(string Status, List<Row> Rows, int Pages);
|
||||
|
||||
private sealed record EventRead(string Status, List<EventRow> Rows, int Pages);
|
||||
|
||||
private sealed record Case(
|
||||
string Name, string? Tag, string? Window, string Path, string Status, string? Note, List<Row>? Rows,
|
||||
Dictionary<string, string?>? Attributes = null, List<EventRow>? Events = null);
|
||||
|
||||
private sealed class Report(string label, string endpoint)
|
||||
{
|
||||
public string Label { get; } = label;
|
||||
public string Endpoint { get; } = endpoint;
|
||||
public string SdkServerAssembly { get; } = typeof(Opc.Ua.Server.StandardServer).Assembly.GetName().Version?.ToString() ?? "?";
|
||||
public DateTime RunUtc { get; } = DateTime.UtcNow;
|
||||
public List<Case> Cases { get; } = [];
|
||||
public List<string> Failures { get; set; } = [];
|
||||
public void Add(Case c) => Cases.Add(c);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -64,7 +64,8 @@ public sealed class AvevaGatewayHistoryLiveGateTests
|
||||
private const string MissingTag = "OtOpcUa.Gate801.DoesNotExist";
|
||||
private const double IntervalMs = 60_000;
|
||||
|
||||
// The ten per-sample aggregates the Gateway backend serves (#681) and a set it must refuse (time-weighted / never).
|
||||
// The aggregates the Gateway backend serves: the ten per-sample ones (#681) plus AVEVA's native TimeAverage / Total (on by
|
||||
// default since lmxopcua#694), and a set it must refuse (the other time-weighted ones / never).
|
||||
private static readonly (string Name, NodeId Id, HistoryAggregateType? Domain)[] Served =
|
||||
[
|
||||
("Average", ObjectIds.AggregateFunction_Average, HistoryAggregateType.Average),
|
||||
@@ -77,12 +78,12 @@ public sealed class AvevaGatewayHistoryLiveGateTests
|
||||
("Delta", ObjectIds.AggregateFunction_Delta, HistoryAggregateType.Delta),
|
||||
("MinimumActualTime", ObjectIds.AggregateFunction_MinimumActualTime, HistoryAggregateType.MinimumActualTime),
|
||||
("MaximumActualTime", ObjectIds.AggregateFunction_MaximumActualTime, HistoryAggregateType.MaximumActualTime),
|
||||
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
|
||||
("Total", ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total),
|
||||
];
|
||||
|
||||
private static readonly (string Name, NodeId Id)[] Refused =
|
||||
[
|
||||
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage),
|
||||
("Total", ObjectIds.AggregateFunction_Total),
|
||||
("TimeAverage2", ObjectIds.AggregateFunction_TimeAverage2),
|
||||
("Interpolative", ObjectIds.AggregateFunction_Interpolative),
|
||||
("PercentGood", ObjectIds.AggregateFunction_PercentGood),
|
||||
@@ -186,7 +187,7 @@ public sealed class AvevaGatewayHistoryLiveGateTests
|
||||
var caps = await ReadHistoryCapabilitiesAsync(session, ct);
|
||||
report.Add(new Case("metadata.historyServerCapabilities", null, null, "wire", "Good", null, caps));
|
||||
Check(failures, caps["AggregateFunctions"] == string.Join(",", Served.Select(a => a.Name).OrderBy(n => n, StringComparer.Ordinal)),
|
||||
$"advertised history aggregates must be exactly the Gateway's ten, got {caps["AggregateFunctions"]}");
|
||||
$"advertised history aggregates must be exactly the Gateway's twelve, got {caps["AggregateFunctions"]}");
|
||||
// lmxopcua#881: the Boolean capabilities follow the Gateway backend — data + event reads, no HistoryUpdate path, no
|
||||
// stored server timestamps, never event / annotation updates (this gate recorded all-false before the fix).
|
||||
foreach (var flag in new[] { "AccessHistoryDataCapability", "AccessHistoryEventsCapability" })
|
||||
|
||||
Reference in New Issue
Block a user