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.
107 lines
4.9 KiB
C#
107 lines
4.9 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
|
|
|
/// <summary>
|
|
/// The direction-aware event read (Gitea #675): forward is <c>[lo, hi)</c> ascending by <c>(EventTimeUtc, EventId)</c>;
|
|
/// reverse is the Part 11 reverse window <c>(lo, hi]</c> DESCENDING by the same key, so a capped reverse read returns the
|
|
/// LATEST events and flags truncation. Paging a reverse read the way the server does — resume at the last returned
|
|
/// time inclusive and drop the boundary ties already emitted — must visit every event exactly once, newest-first,
|
|
/// through a same-timestamp tie cluster.
|
|
/// </summary>
|
|
public abstract class EventReadConformance : ConformanceBase
|
|
{
|
|
private static readonly CancellationToken Ct = CancellationToken.None;
|
|
|
|
/// <summary>e0@0, e1@1, then the ties t0..t4@2, then e5@3 and e6@4 (all source "s"), plus o0@2 from another source.</summary>
|
|
private async Task<IHistorianDataSource> SeedAsync()
|
|
{
|
|
await B.SeedEventsAsync(
|
|
(At(0), "e0", "s"), (At(1), "e1", "s"),
|
|
(At(2), "t0", "s"), (At(2), "t1", "s"), (At(2), "t2", "s"), (At(2), "t3", "s"), (At(2), "t4", "s"),
|
|
(At(3), "e5", "s"), (At(4), "e6", "s"),
|
|
(At(2), "o0", "other"));
|
|
return B.CreateDataSource();
|
|
}
|
|
|
|
private static readonly string[] NewestFirst = ["e6", "e5", "t4", "t3", "t2", "t1", "t0", "e1", "e0"];
|
|
|
|
[Fact]
|
|
public async Task Reverse_read_is_newest_first_by_time_then_EventId_descending()
|
|
{
|
|
using var src = await SeedAsync();
|
|
var r = await src.ReadEventsAsync("s", At(-1), At(10), 0, reverse: true, Ct);
|
|
r.Events.Select(e => e.EventId).ShouldBe(NewestFirst);
|
|
r.ContinuationPoint.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reverse_window_is_end_exclusive_start_inclusive_whichever_order_the_bounds_come_in()
|
|
{
|
|
using var src = await SeedAsync();
|
|
// (At(1), At(3)]: e1 at the lower bound is excluded, e5 at the upper bound included.
|
|
(await src.ReadEventsAsync("s", At(3), At(1), 0, reverse: true, Ct)).Events.Select(e => e.EventId)
|
|
.ShouldBe(["e5", "t4", "t3", "t2", "t1", "t0"]);
|
|
(await src.ReadEventsAsync("s", At(1), At(3), 0, reverse: true, Ct)).Events.Select(e => e.EventId)
|
|
.ShouldBe(["e5", "t4", "t3", "t2", "t1", "t0"]);
|
|
// Forward is unchanged: [At(1), At(3)) ascending.
|
|
(await src.ReadEventsAsync("s", At(1), At(3), 0, reverse: false, Ct)).Events.Select(e => e.EventId)
|
|
.ShouldBe(["e1", "t0", "t1", "t2", "t3", "t4"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_capped_reverse_read_keeps_the_LATEST_events_and_flags_truncation()
|
|
{
|
|
using var src = await SeedAsync();
|
|
var r = await src.ReadEventsAsync("s", At(10), At(-1), 3, reverse: true, Ct);
|
|
r.Events.Select(e => e.EventId).ShouldBe(["e6", "e5", "t4"]);
|
|
r.ContinuationPoint.ShouldNotBeNull();
|
|
// An exact fit is complete.
|
|
(await src.ReadEventsAsync("s", At(10), At(-1), 9, reverse: true, Ct)).ContinuationPoint.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Paging_a_reverse_read_through_a_tie_cluster_visits_every_event_once_newest_first()
|
|
{
|
|
using var src = await SeedAsync();
|
|
foreach (var cap in new[] { 1, 2, 3, 4 })
|
|
{
|
|
var all = new List<string>();
|
|
var lo = At(-1);
|
|
var from = At(10);
|
|
var skip = 0;
|
|
for (var page = 0; ; page++)
|
|
{
|
|
page.ShouldBeLessThan(40, "paging must terminate");
|
|
var r = await src.ReadEventsAsync("s", from, lo, cap + skip, reverse: true, Ct);
|
|
var events = r.Events.ToList();
|
|
var drop = 0;
|
|
while (drop < skip && drop < events.Count && events[drop].EventTimeUtc == from) drop++;
|
|
events = events.Skip(drop).ToList();
|
|
// The server asks for cap + skip rows (it re-reads the boundary ties it then drops), so a page is never
|
|
// emptied by the trim while more remains.
|
|
if (r.ContinuationPoint is not null) events.Count.ShouldBe(cap);
|
|
|
|
all.AddRange(events.Select(e => e.EventId));
|
|
if (r.ContinuationPoint is null) break;
|
|
var boundary = events[^1].EventTimeUtc;
|
|
var ties = events.AsEnumerable().Reverse().TakeWhile(e => e.EventTimeUtc == boundary).Count();
|
|
skip = boundary == from ? skip + ties : ties;
|
|
from = boundary;
|
|
}
|
|
|
|
all.SequenceEqual(NewestFirst).ShouldBeTrue($"cap {cap}: {string.Join(",", all)}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reverse_read_with_a_null_source_spans_every_source()
|
|
{
|
|
using var src = await SeedAsync();
|
|
var r = await src.ReadEventsAsync(null, At(2.5), At(1.5), 0, reverse: true, Ct);
|
|
r.Events.Select(e => e.EventId).ShouldBe(["t4", "t3", "t2", "t1", "t0", "o0"]);
|
|
}
|
|
}
|