3c3513ea3e
Item 1: IHistorianDataSource.MaxRawValuesPerRead (Gateway: learned ceiling - 16 = L; 0 elsewhere, guard-tested). RawPageLimit plans every raw page on top of #692's lookahead: with L > 0 the read (page P + boundary ties k + lookahead) is <= L, P = min(N, L - k - 1) (uncapped: L - k - 1); no room => straight to the tie-cluster over-fetch (never an uncapped or underflowing request). One re-plan + re-read when the read taught a new limit or was refused for crowding pre-window rows (HistoryReadLimitExceededException.PreWindowRows): the page shrinks by them. Over-fetch bound min(MaxTieClusterOverfetch, L - 1); slice size never 0. Review fixes: F1 a window starting at/before the epoch counts AVEVA's synthetic row as pre-window (a ceiling-truncated read was reported complete); F2 the page path disables ReverseRawReader's own re-run (cold end-only ceiling 20/N=3000 cost 3,023 reads, now <= 40); F3 crowded look-back re-read around, not refused; F4 the #687 crowding re-read room at least doubles (up to 8 re-reads), so a cluster of hundreds just before a resume point no longer fails on a default gateway; a crowding refusal that leaves no room is BadTooManyOperations, never a tick-by-tick cursor. Item 2: ServerHistorian:EventReadHorizon (default 00:00:00 = exact, unchanged). When set, every fresh HistoryReadEvents window's earlier bound is raised to now - horizon (both directions, all backends). Lossy by design; documented. Item 3 (native TimeAverage/Total re-check) stays blocked on historiangw#27.
232 lines
11 KiB
C#
232 lines
11 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Opc.Ua;
|
||
using Opc.Ua.Server;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||
|
||
/// <summary>
|
||
/// Gitea #694 item 2 (live gate F4): a walked-back events read on the Gateway proves "nothing older" with one forward read
|
||
/// down to the Unix epoch (#691) — ~3.8 s live on <c>Runtime.dbo.Events</c>, and an unfiltered deep read hit the gateway's
|
||
/// 30 s SQL timeout. The opt-in <c>ServerHistorian:EventReadHorizon</c> (<see cref="OtOpcUaNodeManager.EventReadHorizon"/>)
|
||
/// bounds every events read at <c>now − horizon</c>, in both directions; off (the default) nothing changes. The backend is
|
||
/// shaped like the Gateway's SQL event read: forward only, <c>[start, end]</c> inclusive, refuses <c>start > end</c>
|
||
/// (counted), records every requested window.
|
||
/// </summary>
|
||
public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable
|
||
{
|
||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||
private static readonly DateTime Now = new(2026, 10, 13, 12, 0, 0, DateTimeKind.Utc);
|
||
private static readonly TimeSpan Horizon = TimeSpan.FromDays(30);
|
||
private static readonly DateTime Floor = Now - Horizon;
|
||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-eventhorizon-{Guid.NewGuid():N}");
|
||
|
||
/// <summary>Five recent events (the last five minutes) — a source with little history, as in the live gate.</summary>
|
||
private static List<HistoricalEvent> Recent() =>
|
||
Enumerable.Range(0, 5).Select(i => Ev($"r{i}", Now.AddMinutes(-5 + i))).ToList();
|
||
|
||
[Fact]
|
||
public async Task Without_a_horizon_a_walked_reverse_read_proves_the_rest_empty_down_to_the_epoch()
|
||
{
|
||
// The positive control: today's exact behaviour, and the cost the horizon removes.
|
||
var (host, nm, node, gw) = await SetupAsync(Recent());
|
||
try
|
||
{
|
||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now);
|
||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||
r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]);
|
||
gw.Requests.Min(q => q.Start).ShouldBe(DateTime.UnixEpoch);
|
||
gw.BadRequests.ShouldBe(0);
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
[Fact]
|
||
public async Task With_a_horizon_the_walk_never_reads_before_it_and_answers_the_same()
|
||
{
|
||
var (host, nm, node, gw) = await SetupAsync(Recent());
|
||
try
|
||
{
|
||
nm.EventReadHorizon = Horizon;
|
||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now);
|
||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||
r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]);
|
||
r.Cp.ShouldBeNull();
|
||
// Requests are rounded outward to the millisecond, so the earliest one may start up to 1 ms before the floor.
|
||
gw.Requests.Min(q => q.Start).ShouldBeGreaterThanOrEqualTo(Floor.AddMilliseconds(-1));
|
||
gw.BadRequests.ShouldBe(0);
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Events_older_than_the_horizon_are_not_returned_in_either_direction()
|
||
{
|
||
var events = new List<HistoricalEvent> { Ev("old", Floor.AddDays(-10)), Ev("edge", Floor), Ev("new", Now.AddDays(-1)) };
|
||
var (host, nm, node, gw) = await SetupAsync(events);
|
||
try
|
||
{
|
||
nm.EventReadHorizon = Horizon;
|
||
|
||
var reverse = ReadEvents(nm, node, max: 10, start: Now, end: DateTime.UnixEpoch);
|
||
reverse.Ids.ShouldBe(["new", "edge"]); // the floor instant itself is inside
|
||
|
||
var forward = ReadEvents(nm, node, max: 10, start: DateTime.UnixEpoch, end: Now);
|
||
forward.Ids.ShouldBe(["edge", "new"]);
|
||
|
||
gw.Requests.ShouldAllBe(q => q.Start >= Floor.AddMilliseconds(-1));
|
||
|
||
nm.EventReadHorizon = TimeSpan.Zero; // off again: exact to the window
|
||
ReadEvents(nm, node, max: 10, start: DateTime.UnixEpoch, end: Now).Ids.ShouldBe(["old", "edge", "new"]);
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_window_wholly_older_than_the_horizon_is_GoodNoData_and_never_sent()
|
||
{
|
||
var (host, nm, node, gw) = await SetupAsync([Ev("old", Floor.AddDays(-10))]);
|
||
try
|
||
{
|
||
nm.EventReadHorizon = Horizon;
|
||
var forward = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-20), end: Floor.AddDays(-1));
|
||
forward.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||
forward.Status.Code.ShouldBe(StatusCodes.GoodNoData);
|
||
forward.Ids.ShouldBeEmpty();
|
||
|
||
var reverse = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-1), end: Floor.AddDays(-20));
|
||
reverse.Status.Code.ShouldBe(StatusCodes.GoodNoData);
|
||
|
||
gw.Requests.ShouldBeEmpty();
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_page_through_under_a_horizon_keeps_its_floor_across_continuation_points()
|
||
{
|
||
var events = Enumerable.Range(0, 25).Select(i => Ev($"e{i:00}", Now.AddMinutes(-i))).ToList();
|
||
events.Add(Ev("old", Floor.AddDays(-1)));
|
||
var (host, nm, node, gw) = await SetupAsync(events);
|
||
try
|
||
{
|
||
nm.EventReadHorizon = Horizon;
|
||
var all = new List<string>();
|
||
byte[]? cp = null;
|
||
var pages = 0;
|
||
do
|
||
{
|
||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now, inboundCp: cp);
|
||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||
all.AddRange(r.Ids);
|
||
cp = r.Cp;
|
||
(++pages).ShouldBeLessThan(10);
|
||
}
|
||
while (cp is not null);
|
||
|
||
all.ShouldBe(Enumerable.Range(0, 25).Select(i => $"e{i:00}"));
|
||
gw.Requests.ShouldAllBe(q => q.Start >= Floor.AddMilliseconds(-1));
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
// ---- helpers ---------------------------------------------------------------------------------------------------------
|
||
|
||
private static HistoricalEvent Ev(string id, DateTime at) => new(id, "src", at, at, "m", 500);
|
||
|
||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, GatewayShapedEventSource Gw)> SetupAsync(
|
||
List<HistoricalEvent> events)
|
||
{
|
||
var started = await OpcUaHostStart.StartAsync(
|
||
port => new OpcUaApplicationHost(
|
||
new OpcUaApplicationHostOptions
|
||
{
|
||
ApplicationName = "OtOpcUa.EventHorizonTest",
|
||
ApplicationUri = $"urn:OtOpcUa.EventHorizonTest:{Guid.NewGuid():N}",
|
||
OpcUaPort = port,
|
||
PublicHostname = "localhost",
|
||
PkiStoreRoot = _pkiRoot,
|
||
},
|
||
NullLogger<OpcUaApplicationHost>.Instance),
|
||
() => new OtOpcUaSdkServer(), Ct);
|
||
var nm = started.Server.NodeManager!;
|
||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||
nm.Clock = new FixedClock(Now);
|
||
var gw = new GatewayShapedEventSource(events);
|
||
nm.HistorianDataSource = gw;
|
||
nm.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||
nm.MaterialiseAlarmCondition("alarm-1", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||
return (started.Host, nm, nm.TryGetFolder("eq-evt")!.NodeId, gw);
|
||
}
|
||
|
||
private static (List<string> Ids, ServiceResult Error, StatusCode Status, byte[]? Cp) ReadEvents(
|
||
OtOpcUaNodeManager nm, NodeId node, uint max, DateTime start, DateTime end, byte[]? inboundCp = null)
|
||
{
|
||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||
var filter = new EventFilter();
|
||
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||
{
|
||
AttributeId = Attributes.Value,
|
||
});
|
||
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, Filter = filter };
|
||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = inboundCp } };
|
||
var results = new List<SdkHistoryReadResult> { null! };
|
||
var errors = new List<ServiceResult> { null! };
|
||
nm.HistoryRead(context, details, TimestampsToReturn.Both, false, nodesToRead, results, errors);
|
||
|
||
var ids = new List<string>();
|
||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||
foreach (var e in ((HistoryEvent)ExtensionObject.ToEncodeable(ho)).Events)
|
||
ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
|
||
return (ids, errors[0], results[0]?.StatusCode ?? StatusCodes.Good, results[0]?.ContinuationPoint);
|
||
}
|
||
|
||
/// <summary>The Gateway's SQL event read: forward only, <c>[start, end]</c> inclusive, time-ordered, capped + flag.</summary>
|
||
private sealed class GatewayShapedEventSource(List<HistoricalEvent> events) : IHistorianDataSource
|
||
{
|
||
public List<(DateTime Start, DateTime End, int Max)> Requests { get; } = [];
|
||
public int BadRequests { get; private set; }
|
||
public bool SupportsReverseEvents => false;
|
||
|
||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct)
|
||
{
|
||
Requests.Add((s, e, m));
|
||
if (s > e) BadRequests++; // the gateway's ValidateRange
|
||
var window = events.Where(x => x.EventTimeUtc >= s && x.EventTimeUtc <= e).OrderBy(x => x.EventTimeUtc).ToList();
|
||
var cap = m <= 0 ? int.MaxValue : m;
|
||
return Task.FromResult(new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null));
|
||
}
|
||
|
||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct) =>
|
||
reverse ? throw new NotSupportedException("forward only") : ReadEventsAsync(n, s, e, m, ct);
|
||
|
||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||
public void Dispose() { }
|
||
}
|
||
|
||
private sealed class FixedClock(DateTime now) : TimeProvider
|
||
{
|
||
public override DateTimeOffset GetUtcNow() => new(now);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (Directory.Exists(_pkiRoot))
|
||
{
|
||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||
catch { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|