93d6c1da67
- IHistorianDataSource.SupportsReverseEvents (default false; SqlServer/QuestDB true; Gateway/Null declare false). The node manager checks it BEFORE calling the source: an end-only capped events read becomes reverse only on a supporting backend (Gateway, the default, keeps the pre-#675 forward [MinValue, EndTime) paging), and an explicit reverse read on a non-supporting backend is BadHistoryOperationUnsupported without a call. - Removed the broad catch (NotSupportedException) in the events path: a NotSupportedException from inside a backend is now logged as a backend fault (Error), not an 'unsupported' Warning. - Null declares the reverse overload itself; guard test: every production IHistorianDataSource declares both the reverse overload and the capability (positive-control scan of all four backends). - Conformance: capability reported + oversized reverse tie cluster (forward over-fetch reversed == reverse read order; capped reverse read stopping inside the cluster flags truncation) on SqlServer + QuestDB. - docs/Historian.md: end-only capped events read is backward on SqlServer/QuestDB, forward on Gateway.
143 lines
7.1 KiB
C#
143 lines
7.1 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 void The_backend_reports_reverse_support()
|
|
{
|
|
using var src = B.CreateDataSource();
|
|
src.SupportsReverseEvents.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_oversized_reverse_tie_cluster_sliced_the_way_the_server_does_matches_the_reverse_read_order()
|
|
{
|
|
// The server pages WITHIN a tie cluster larger than the page by over-fetching it FORWARD over [T, T + resolution)
|
|
// and reversing it for a reverse read (OtOpcUaNodeManager.ServeEventsAsync + HistoryPaging.SliceTieCluster). That is
|
|
// only correct if this backend's forward cluster, reversed, is exactly the order its reverse read emits the ties in
|
|
// — the tie-breaker must be the same key in both directions. Drive both through the real backend.
|
|
await B.SeedEventsAsync(Enumerable.Range(0, 12).Select(i => (At(2), $"c{i:00}", "s")).Append((At(3), "z", "s")).ToArray());
|
|
using var src = B.CreateDataSource();
|
|
|
|
var reverseOrder = (await src.ReadEventsAsync("s", At(10), At(0), 0, reverse: true, Ct)).Events
|
|
.Where(e => e.EventTimeUtc == B.Round(At(2))).Select(e => e.EventId).ToList();
|
|
var clusterReversed = (await src.ReadEventsAsync("s", B.Round(At(2)), B.Round(At(2)).AddTicks(B.ResolutionTicks), 1000, reverse: false, Ct))
|
|
.Events.Select(e => e.EventId).Reverse().ToList();
|
|
|
|
clusterReversed.Count.ShouldBe(12);
|
|
clusterReversed.ShouldBe(reverseOrder);
|
|
|
|
// And slicing it at a cap of 5 (skip 0, 5, 10) walks the cluster newest-first with no duplicate or gap.
|
|
var slices = new List<string>();
|
|
for (var skip = 0; skip < clusterReversed.Count; skip += 5) slices.AddRange(clusterReversed.Skip(skip).Take(5));
|
|
slices.ShouldBe(Enumerable.Range(0, 12).Reverse().Select(i => $"c{i:00}"));
|
|
|
|
// A capped reverse read stopping inside the cluster flags truncation, so the server's cluster branch is reachable.
|
|
var capped = await src.ReadEventsAsync("s", At(10), At(0), 4, reverse: true, Ct);
|
|
capped.Events.Select(e => e.EventId).ShouldBe(["z", "c11", "c10", "c09"]);
|
|
capped.ContinuationPoint.ShouldNotBeNull();
|
|
}
|
|
|
|
[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"]);
|
|
}
|
|
}
|