Merge feat/675-reverse-event-paging follow-ups: SupportsReverseEvents capability (Gateway end-only stays forward), no broad NotSupported catch, guard test (#675 review)
This commit is contained in:
+14
-8
@@ -646,14 +646,20 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un
|
||||
forward) whatever the resume request's details say. Reverse tie clusters are over-fetched forward and sliced from the newest
|
||||
end, exactly as Raw does. Before #675 a capped reverse read silently returned the EARLIEST N, ascending, Good, with no
|
||||
continuation point.
|
||||
- **End-only events reads read backward (#675).** `StartTime` unset (`DateTime.MinValue`) with an `EndTime` and
|
||||
`NumValuesPerNode > 0` pages backward from `EndTime` over `(MinValue, EndTime]` — the same rule as Raw. Start-only reads still
|
||||
page forward to an open upper bound (`DateTime.MaxValue`, UTC). Without a cap an end-only read keeps its forward
|
||||
`[MinValue, EndTime)` meaning.
|
||||
- **Backends.** The server calls `IHistorianDataSource.ReadEventsAsync(…, bool reverse, …)`. SqlServer and QuestDB serve reverse
|
||||
natively (`… > lo AND … <= hi ORDER BY time DESC, id DESC`). **Gateway and Null refuse a reverse read** (the interface default
|
||||
throws `NotSupportedException`) and the node answers `BadHistoryOperationUnsupported` — the gateway streams ascending only and
|
||||
rejects `end < start`, and draining the whole window to keep its tail would be exactly the unbounded read the cap prevents.
|
||||
- **End-only events reads: backward on SqlServer/QuestDB, forward on Gateway (#675).** `StartTime` unset
|
||||
(`DateTime.MinValue`) with an `EndTime` and `NumValuesPerNode > 0` pages backward from `EndTime` over `(MinValue, EndTime]` —
|
||||
the same rule as Raw — **only on a backend that reports `SupportsReverseEvents`** (SqlServer, QuestDB). On Gateway (the
|
||||
default backend) and Null it keeps its pre-#675 forward `[MinValue, EndTime)` meaning, paged oldest-first, rather than
|
||||
becoming a refused reverse read. Start-only reads page forward to an open upper bound (`DateTime.MaxValue`, UTC) everywhere.
|
||||
Without a cap an end-only read is forward `[MinValue, EndTime)` everywhere.
|
||||
- **Backends.** The server checks `IHistorianDataSource.SupportsReverseEvents` and then calls
|
||||
`ReadEventsAsync(…, bool reverse, …)`. SqlServer and QuestDB serve reverse natively (`… > lo AND … <= hi ORDER BY time DESC,
|
||||
id DESC`) and report `true`. **Gateway and Null report `false`**: an explicit reverse read (`StartTime > EndTime`) is answered
|
||||
`BadHistoryOperationUnsupported` **without calling the backend** — the gateway streams ascending only and rejects `end < start`,
|
||||
and draining the whole window to keep its tail would be exactly the unbounded read the cap prevents. Because the refusal is a
|
||||
capability check, not a caught exception, a `NotSupportedException` thrown from inside a backend (a driver library) is logged
|
||||
as a backend fault like any other. Every implementation (and any future wrapper) must declare both members itself — the
|
||||
interface defaults (`false` / throw) would silently disable reverse reads; `HistorianDataSourceReverseEventsGuardTests` pins it.
|
||||
- **Gateway open upper bound.** The `DateTime.MaxValue` (UTC) bound of a start-only forward read is pinned offline through the
|
||||
published client's proto `Timestamp` conversion (captured by an in-process gRPC server) and the FILETIME encoding
|
||||
(`GatewayEventBoundsTests`). What a live AVEVA Historian does with a year-9999 bound is still unverified (needs the VPN).
|
||||
|
||||
@@ -101,6 +101,16 @@ public interface IHistorianDataSource : IDisposable
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
|
||||
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/> (Gitea #675). The server
|
||||
/// checks it BEFORE calling the source: an explicit reverse read (<c>StartTime > EndTime</c>) on a backend that
|
||||
/// cannot is answered <c>BadHistoryOperationUnsupported</c> without a call, and an end-only capped read, which Part 11
|
||||
/// reads backward, keeps its forward <c>[MinValue, EndTime)</c> meaning there. Default <c>false</c>; an
|
||||
/// implementation (or a wrapper) declares it itself — a guard test pins that.
|
||||
/// </summary>
|
||||
bool SupportsReverseEvents => false;
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm / event records in a chosen direction — the overload the server's HistoryReadEvents
|
||||
/// paging calls (Gitea #675).
|
||||
@@ -118,8 +128,9 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <para>
|
||||
/// The default forwards a forward read to the five-argument overload and throws
|
||||
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (Gateway,
|
||||
/// Null) — which the server reports as <c>BadHistoryOperationUnsupported</c> rather than returning the
|
||||
/// earliest events in ascending order as if they were the answer.
|
||||
/// Null). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and
|
||||
/// answers <c>BadHistoryOperationUnsupported</c> without calling, rather than returning the earliest events in
|
||||
/// ascending order as if they were the answer. A backend that serves reverse must override BOTH members.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||
|
||||
@@ -55,6 +55,25 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks><c>false</c> (Gitea #675): there is no history to read in either direction, and an explicit reverse read
|
||||
/// is answered <c>BadHistoryOperationUnsupported</c> like any backend that cannot read newest-first.</remarks>
|
||||
public bool SupportsReverseEvents => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Forward ⇒ the empty result; reverse ⇒ <see cref="NotSupportedException"/>, the interface contract for a
|
||||
/// backend without <see cref="SupportsReverseEvents"/>.</remarks>
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
bool reverse,
|
||||
CancellationToken cancellationToken) =>
|
||||
reverse
|
||||
? throw new NotSupportedException("The null historian cannot read events newest-first (reverse).")
|
||||
: Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new(
|
||||
TotalQueries: 0,
|
||||
|
||||
@@ -125,6 +125,13 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>false</c>: the gateway's <c>ReadEvents</c> streams ascending only (Gitea #675). The server therefore refuses an
|
||||
/// explicit reverse events read without calling, and keeps an end-only capped read forward.
|
||||
/// </remarks>
|
||||
public bool SupportsReverseEvents => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A reverse (newest-first) read is refused with <see cref="NotSupportedException"/> — the server reports
|
||||
|
||||
@@ -354,6 +354,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Events
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
|
||||
public bool SupportsReverseEvents => true;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>[start, end)</c> by event time (a reversed window is swapped), ordered by <c>(ts, event_id)</c> ascending.
|
||||
|
||||
+5
@@ -311,6 +311,11 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Events
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
|
||||
public bool SupportsReverseEvents => true;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>[start, end)</c> by <c>EventTimeUtc</c> (a reversed window is swapped), ordered by
|
||||
|
||||
@@ -5189,6 +5189,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
|
||||
var resumed = inboundCp is { Length: > 0 };
|
||||
// One snapshot of the (hot-swappable) source for the whole read: the capability check and every call must agree.
|
||||
var source = _historianDataSource;
|
||||
try
|
||||
{
|
||||
var startUtc = details.StartTime;
|
||||
@@ -5224,17 +5226,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
|
||||
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
|
||||
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
|
||||
// (#675, mirroring raw): the reverse window (MinValue, EndTime]. Without a count cap an end-only read
|
||||
// keeps its historical forward [MinValue, EndTime) meaning.
|
||||
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — but only on a backend that can read
|
||||
// newest-first. Elsewhere (Gateway, the default backend) and without a count cap an end-only read keeps its
|
||||
// historical forward [MinValue, EndTime) meaning rather than becoming a refused reverse read.
|
||||
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
||||
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
|
||||
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue
|
||||
&& source.SupportsReverseEvents)
|
||||
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
||||
|
||||
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
|
||||
reverse = startUtc > endUtc;
|
||||
}
|
||||
|
||||
// An explicit reverse read (or a reverse cursor resumed after the source was swapped) on a backend that cannot
|
||||
// read newest-first: refuse it BEFORE calling the source — never the earliest events in ascending order as if
|
||||
// they were the answer. Checked here rather than by catching an exception, so a NotSupportedException thrown
|
||||
// from inside a backend (a driver library, the projection) stays a backend fault.
|
||||
if (reverse && !source.SupportsReverseEvents)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
return;
|
||||
}
|
||||
|
||||
// NOT under the node-manager Lock — awaiting the async source is safe here.
|
||||
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
|
||||
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel. A resumed page re-reads its
|
||||
@@ -5243,7 +5258,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
var fetch = EventMaxEvents(numValuesPerNode);
|
||||
if (numValuesPerNode > 0 && boundarySkip > 0)
|
||||
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
|
||||
var sourceResult = await _historianDataSource.ReadEventsAsync(
|
||||
var sourceResult = await source.ReadEventsAsync(
|
||||
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
|
||||
|
||||
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
|
||||
@@ -5278,9 +5293,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Oversized tie cluster: more events share startUtc than the page cap, so (time, skip) alone
|
||||
// cannot advance. Over-fetch the whole cluster over the half-open [T, T+resolution) window
|
||||
// with an explicit bounded cap and page WITHIN it (same scheme as raw).
|
||||
var resolution = Math.Max(1L, _historianDataSource.TimestampResolutionTicks);
|
||||
var resolution = Math.Max(1L, source.TimestampResolutionTicks);
|
||||
var overfetchCap = (int)Math.Min(int.MaxValue - 1L, (long)Math.Max(1, MaxTieClusterOverfetch) + 1);
|
||||
IReadOnlyList<HistoricalEvent> cluster = (await _historianDataSource.ReadEventsAsync(
|
||||
IReadOnlyList<HistoricalEvent> cluster = (await source.ReadEventsAsync(
|
||||
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
|
||||
.ConfigureAwait(false)).Events;
|
||||
|
||||
@@ -5345,15 +5360,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
// The backend cannot serve this read's shape — today a reverse (newest-first) read on Gateway / Null
|
||||
// (#675). A client request the backend refuses, not a backend fault: Warning, not Error.
|
||||
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents for node {NodeId} is not supported by the historian backend: {Reason}",
|
||||
handle.NodeId, ex.Message);
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #675 review: the reverse-events surface is two interface members with defaults
|
||||
/// (<see cref="IHistorianDataSource.SupportsReverseEvents"/> = false and the direction-aware
|
||||
/// <c>ReadEventsAsync(…, bool reverse, …)</c>). A future wrapper / decorator that forwards only the five-argument read
|
||||
/// and the other members would compile and silently fall back to the defaults — turning every reverse read into
|
||||
/// <c>BadHistoryOperationUnsupported</c> on a backend that supports it. So every production implementation must declare
|
||||
/// BOTH members itself.
|
||||
/// </summary>
|
||||
public sealed class HistorianDataSourceReverseEventsGuardTests
|
||||
{
|
||||
private static IReadOnlyList<Type> ProductionImplementations()
|
||||
{
|
||||
// Every ZB.MOM.WW.OtOpcUa production assembly next to the tests (this project references every historian backend).
|
||||
var dir = AppContext.BaseDirectory;
|
||||
var assemblies = Directory.GetFiles(dir, "ZB.MOM.WW.OtOpcUa.*.dll")
|
||||
.Where(f => !Path.GetFileName(f).Contains(".Tests", StringComparison.Ordinal)
|
||||
&& !Path.GetFileName(f).Contains("IntegrationTests", StringComparison.Ordinal))
|
||||
.Select(f => Assembly.LoadFrom(f));
|
||||
return assemblies
|
||||
.SelectMany(a =>
|
||||
{
|
||||
try { return a.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { return e.Types.Where(t => t is not null).Cast<Type>(); }
|
||||
})
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IHistorianDataSource).IsAssignableFrom(t))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_scan_finds_every_known_backend()
|
||||
{
|
||||
// Positive control: a scan that finds nothing would pass the guard below vacuously.
|
||||
var names = ProductionImplementations().Select(t => t.Name).ToList();
|
||||
names.ShouldContain("NullHistorianDataSource");
|
||||
names.ShouldContain("GatewayHistorianDataSource");
|
||||
names.ShouldContain("SqlServerHistorianDataSource");
|
||||
names.ShouldContain("QuestDbHistorianDataSource");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_implementation_declares_the_reverse_overload_and_the_capability_itself()
|
||||
{
|
||||
foreach (var type in ProductionImplementations())
|
||||
{
|
||||
var map = type.GetInterfaceMap(typeof(IHistorianDataSource));
|
||||
MethodInfo Impl(Func<MethodInfo, bool> pick) =>
|
||||
map.TargetMethods[Array.FindIndex(map.InterfaceMethods, m => pick(m))];
|
||||
|
||||
var reverseRead = Impl(m => m.Name == nameof(IHistorianDataSource.ReadEventsAsync)
|
||||
&& m.GetParameters().Any(p => p.Name == "reverse"));
|
||||
reverseRead.DeclaringType.ShouldBe(type,
|
||||
$"{type.FullName} must declare ReadEventsAsync(…, bool reverse, …) itself, not inherit the interface default");
|
||||
|
||||
var capability = Impl(m => m.Name == "get_" + nameof(IHistorianDataSource.SupportsReverseEvents));
|
||||
capability.DeclaringType.ShouldBe(type,
|
||||
$"{type.FullName} must declare SupportsReverseEvents itself, not inherit the interface default");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,42 @@ public abstract class EventReadConformance : ConformanceBase
|
||||
}
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ public sealed class GatewayEventBoundsTests
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
ds.SupportsReverseEvents.ShouldBeFalse("the server must keep end-only reads forward on the default backend");
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct));
|
||||
|
||||
+2
@@ -134,6 +134,8 @@ public sealed class EventHistoryPagingOverTheWireTests
|
||||
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)
|
||||
|
||||
+101
-2
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
@@ -346,6 +347,103 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
private static readonly DateTime Early = Epoch.AddDays(-1);
|
||||
private static readonly DateTime Late = Epoch.AddDays(1);
|
||||
|
||||
// --- #675 review follow-ups: the reverse-events capability ------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task End_only_capped_read_on_a_backend_without_reverse_support_pages_forward_as_before()
|
||||
{
|
||||
// Gateway (the default backend) cannot read newest-first: an end-only read keeps its pre-#675 forward
|
||||
// [MinValue, EndTime) meaning there rather than turning into a refused reverse read.
|
||||
var (host, nm, node, src) = await SetupAsync(Distinct(25));
|
||||
var fwd = new NonReverseSource(src);
|
||||
nm.HistorianDataSource = fwd;
|
||||
var ids = DrainAsync(nm, node, max: 10, start: DateTime.MinValue, end: Epoch.AddSeconds(20));
|
||||
ids.ShouldBe(Enumerable.Range(0, 20).Select(i => $"e{i:0000}"));
|
||||
fwd.ReverseCalls.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Explicit_reverse_read_on_a_backend_without_reverse_support_is_refused_without_calling_it()
|
||||
{
|
||||
var (host, nm, node, src) = await SetupAsync(Distinct(10));
|
||||
var fwd = new NonReverseSource(src);
|
||||
nm.HistorianDataSource = fwd;
|
||||
ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
fwd.ReverseCalls.ShouldBe(0, "the capability is checked BEFORE the source is called");
|
||||
fwd.ForwardCalls.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_NotSupportedException_from_inside_a_supporting_backend_is_a_backend_fault_not_unsupported()
|
||||
{
|
||||
// A NotSupportedException thrown by a driver library (Npgsql, SqlClient) or the projection is a fault: it must be
|
||||
// logged as an Error like any other backend failure, never downgraded to the "backend cannot read reverse" Warning.
|
||||
var logger = new CapturingLogger();
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(10), logger: logger);
|
||||
nm.HistorianDataSource = new ThrowingReverseSource();
|
||||
ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
logger.Entries.ShouldContain(e => e.Level == LogLevel.Error && e.Exception is NotSupportedException);
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Declares the reverse overload (so a call would be visible) but reports no reverse capability.</summary>
|
||||
private sealed class NonReverseSource(IHistorianDataSource inner) : IHistorianDataSource
|
||||
{
|
||||
public int ReverseCalls { get; private set; }
|
||||
public int ForwardCalls { get; private set; }
|
||||
public bool SupportsReverseEvents => false;
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct)
|
||||
{
|
||||
ForwardCalls++;
|
||||
return inner.ReadEventsAsync(n, s, e, m, ct);
|
||||
}
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct)
|
||||
{
|
||||
if (reverse) { ReverseCalls++; return inner.ReadEventsAsync(n, s, e, m, true, ct); }
|
||||
return ReadEventsAsync(n, s, e, m, ct);
|
||||
}
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
inner.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
inner.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
inner.ReadAtTimeAsync(r, t, ct);
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => inner.GetHealthSnapshot();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Supports reverse, but its read fails inside with a library-level NotSupportedException.</summary>
|
||||
private sealed class ThrowingReverseSource : IHistorianDataSource
|
||||
{
|
||||
public bool SupportsReverseEvents => true;
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
|
||||
throw new NotSupportedException("driver: unsupported wire type");
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct) =>
|
||||
throw new NotSupportedException("driver: unsupported wire type");
|
||||
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 CapturingLogger : ILogger<OtOpcUaNodeManager>
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Enqueue((logLevel, exception, formatter(state, exception)));
|
||||
}
|
||||
|
||||
/// <summary>A backend that implements only the five-argument (forward) event read - Gateway / Null shaped.</summary>
|
||||
private sealed class ForwardOnlyEventSource(IHistorianDataSource inner) : IHistorianDataSource
|
||||
{
|
||||
@@ -396,7 +494,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeEventSource Src)> SetupAsync(
|
||||
List<HistoricalEvent> events, long resolution = 1)
|
||||
List<HistoricalEvent> events, long resolution = 1, ILogger<OtOpcUaNodeManager>? logger = null)
|
||||
{
|
||||
var applicationUri = $"urn:OtOpcUa.EventPagingTest:{Guid.NewGuid():N}";
|
||||
var started = await OpcUaHostStart.StartAsync(
|
||||
@@ -410,7 +508,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(logger), Ct);
|
||||
var nm = started.Server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
var src = new SqlLikeEventSource(events, resolution);
|
||||
@@ -503,6 +601,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
private sealed class SqlLikeEventSource(List<HistoricalEvent> events, long resolution = 1) : IHistorianDataSource
|
||||
{
|
||||
public long TimestampResolutionTicks => resolution;
|
||||
public bool SupportsReverseEvents => true;
|
||||
private DateTime Trunc(DateTime d) => d.AddTicks(-(d.Ticks % resolution));
|
||||
|
||||
public IReadOnlyList<HistoricalEvent> All { get; } =
|
||||
|
||||
Reference in New Issue
Block a user