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.
162 lines
8.2 KiB
C#
162 lines
8.2 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);
|
|
}
|
|
|
|
public bool SupportsReverseEvents => true;
|
|
|
|
/// <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() { }
|
|
}
|
|
}
|