8d154c6a81
- IHistorianDataSource.ReadEventsAsync(..., bool reverse, ...) overload: reverse = Part 11 window (lo, hi] newest-first by (time, id) DESC. Default forwards forward reads and throws NotSupportedException for reverse (Null); Gateway refuses explicitly (ascending-only stream). Server answers BadHistoryOperationUnsupported instead of silently returning the earliest N ascending. - SqlServer / QuestDB serve reverse natively (ORDER BY time DESC, id DESC + TOP/LIMIT). - Node manager: reverse event reads page with continuation points; direction stored in the cursor (HistoryContinuationState.Reverse) so a resume continues in the cursor's direction; reverse tie clusters over-fetched forward and sliced from the newest end (SliceTieCluster(reverse:)); end-only reads with a cap page backward from EndTime (mirrors raw). - Resumed event pages over-fetch by the boundary skip, so every non-final page is a full cap page. - Tests: node-manager reverse paging (ties, clusters, coarse resolution, overfetch bound, resume direction both ways, end-only, unsupported backend); shared EventReadConformance on SqlServer + QuestDB; over-the-wire reverse paging; offline Gateway pin of the UTC DateTime.MaxValue bound through the published client's proto conversion (in-process gRPC capture) and FILETIME. - docs/Historian.md event paging section.
160 lines
8.1 KiB
C#
160 lines
8.1 KiB
C#
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 CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #669 over a real SDK client session: an event HistoryRead capped below the available events pages
|
|
/// through the PRODUCTION session-bound continuation store (<c>SessionHistoryContinuationStore</c>) — the
|
|
/// path the session-less unit tests cannot reach — and the union of pages is every event exactly once.
|
|
/// </summary>
|
|
public sealed class EventHistoryPagingOverTheWireTests
|
|
{
|
|
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public async Task A_capped_event_read_pages_through_the_session_store_exactly_once()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("EvtPagingWire", encrypted: false, ct);
|
|
try
|
|
{
|
|
// Source installed BEFORE the alarm materialises so the folder is promoted with the HistoryRead bit.
|
|
var events = Enumerable.Range(0, 25)
|
|
.Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i / 2), Epoch, "m", 500))
|
|
.ToList(); // pairs share a timestamp, so page boundaries cut through ties
|
|
rig.Nm.HistorianDataSource = new SqlLikeEventSource(events);
|
|
rig.MaterialiseNative();
|
|
rig.Enforce(false, [Key()]);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
|
|
var collected = new List<string>();
|
|
byte[]? cp = null;
|
|
var pages = 0;
|
|
do
|
|
{
|
|
var (ids, next) = await ReadPageAsync(session, node, cp, ct);
|
|
collected.AddRange(ids);
|
|
cp = next;
|
|
(++pages).ShouldBeLessThan(30, "paging must terminate");
|
|
}
|
|
while (cp is not null);
|
|
|
|
pages.ShouldBeGreaterThan(2, "25 events at a cap of 10 must page over the wire");
|
|
collected.ShouldBe(events.Select(e => e.EventId));
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_capped_REVERSE_event_read_pages_newest_first_through_the_session_store_exactly_once()
|
|
{
|
|
// Gitea #675: StartTime later than EndTime. Before the fix the cap kept the EARLIEST 10, ascending, with no
|
|
// continuation point.
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("EvtPagingWireRev", encrypted: false, ct);
|
|
try
|
|
{
|
|
var events = Enumerable.Range(0, 25)
|
|
.Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i / 2), Epoch, "m", 500))
|
|
.ToList(); // pairs share a timestamp, so page boundaries cut through ties
|
|
rig.Nm.HistorianDataSource = new SqlLikeEventSource(events);
|
|
rig.MaterialiseNative();
|
|
rig.Enforce(false, [Key()]);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
|
|
var collected = new List<string>();
|
|
var sizes = new List<int>();
|
|
byte[]? cp = null;
|
|
do
|
|
{
|
|
var (ids, next) = await ReadPageAsync(session, node, cp, ct, reverse: true);
|
|
collected.AddRange(ids);
|
|
sizes.Add(ids.Count);
|
|
cp = next;
|
|
sizes.Count.ShouldBeLessThan(30, "paging must terminate");
|
|
}
|
|
while (cp is not null);
|
|
|
|
sizes.ShouldBe([10, 10, 5]);
|
|
// Newest-first: time descending, ties by EventId descending.
|
|
collected.ShouldBe(events.OrderByDescending(e => e.EventTimeUtc)
|
|
.ThenByDescending(e => e.EventId, StringComparer.Ordinal).Select(e => e.EventId));
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
private static async Task<(List<string> Ids, byte[]? Cp)> ReadPageAsync(
|
|
ISession session, NodeId node, byte[]? cp, CancellationToken ct, bool reverse = false)
|
|
{
|
|
var filter = new EventFilter();
|
|
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
|
{
|
|
AttributeId = Attributes.Value,
|
|
});
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = reverse ? Epoch.AddDays(1) : Epoch.AddDays(-1),
|
|
EndTime = reverse ? Epoch.AddDays(-1) : Epoch.AddDays(1),
|
|
NumValuesPerNode = 10,
|
|
Filter = filter,
|
|
};
|
|
var response = await session.HistoryReadAsync(
|
|
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
|
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node, ContinuationPoint = cp } }, ct);
|
|
var result = response.Results.ShouldHaveSingleItem();
|
|
StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
|
|
var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
|
|
var ids = he.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToList();
|
|
return (ids, result.ContinuationPoint is { Length: > 0 } c ? c : null);
|
|
}
|
|
|
|
/// <summary>(time, id) order, [start, end), cap + truncation flag — the SQL backends' contract.</summary>
|
|
private sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
|
|
{
|
|
private readonly List<HistoricalEvent> _all =
|
|
events.OrderBy(e => e.EventTimeUtc).ThenBy(e => e.EventId, StringComparer.Ordinal).ToList();
|
|
|
|
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct)
|
|
{
|
|
await Task.Delay(1, ct).ConfigureAwait(false); // complete on a pool thread, like a real backend
|
|
var window = _all.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList();
|
|
var cap = maxEvents <= 0 ? 1000 : maxEvents;
|
|
return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null);
|
|
}
|
|
|
|
/// <summary>The #675 overload: reverse is <c>(lo, hi]</c> by (time, id) descending.</summary>
|
|
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken ct)
|
|
{
|
|
if (!reverse) return await ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct).ConfigureAwait(false);
|
|
await Task.Delay(1, ct).ConfigureAwait(false);
|
|
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
|
|
var window = _all.Where(e => e.EventTimeUtc > lo && e.EventTimeUtc <= hi).Reverse().ToList();
|
|
var cap = maxEvents <= 0 ? 1000 : maxEvents;
|
|
return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null);
|
|
}
|
|
|
|
public Task<CoreHistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
|
public Task<CoreHistoryReadResult> ReadProcessedAsync(
|
|
string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
|
public Task<CoreHistoryReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
|
public void Dispose() { }
|
|
}
|
|
}
|