diff --git a/docs/Historian.md b/docs/Historian.md index 971fb43d..d8fc2bab 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -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). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs index a891fd70..e7fcf2c6 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs @@ -101,6 +101,16 @@ public interface IHistorianDataSource : IDisposable int maxEvents, CancellationToken cancellationToken); + /// + /// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of + /// (Gitea #675). The server + /// checks it BEFORE calling the source: an explicit reverse read (StartTime > EndTime) on a backend that + /// cannot is answered BadHistoryOperationUnsupported without a call, and an end-only capped read, which Part 11 + /// reads backward, keeps its forward [MinValue, EndTime) meaning there. Default false; an + /// implementation (or a wrapper) declares it itself — a guard test pins that. + /// + bool SupportsReverseEvents => false; + /// /// 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 /// /// The default forwards a forward read to the five-argument overload and throws /// for a reverse one — a backend that cannot read newest-first (Gateway, - /// Null) — which the server reports as BadHistoryOperationUnsupported 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 first and + /// answers BadHistoryOperationUnsupported 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. /// /// /// The source name to filter events, or null to return events from all sources. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs index e7d2f90b..c5b3e4b2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs @@ -55,6 +55,25 @@ public sealed class NullHistorianDataSource : IHistorianDataSource int maxEvents, CancellationToken cancellationToken) => Task.FromResult(EmptyEvents); + /// + /// false (Gitea #675): there is no history to read in either direction, and an explicit reverse read + /// is answered BadHistoryOperationUnsupported like any backend that cannot read newest-first. + public bool SupportsReverseEvents => false; + + /// + /// Forward ⇒ the empty result; reverse ⇒ , the interface contract for a + /// backend without . + public Task 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); + /// public HistorianHealthSnapshot GetHealthSnapshot() => new( TotalQueries: 0, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index a3e2ebb5..afc1a105 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -125,6 +125,13 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis } } + /// + /// + /// false: the gateway's ReadEvents streams ascending only (Gitea #675). The server therefore refuses an + /// explicit reverse events read without calling, and keeps an end-only capped read forward. + /// + public bool SupportsReverseEvents => false; + /// /// /// A reverse (newest-first) read is refused with — the server reports diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs index a79c976d..381d7ccd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs @@ -354,6 +354,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource // ------------------------------------------------------------------------------------------ Events + /// + /// Reverse events reads are served natively (ORDER BY … DESC, Gitea #675). + public bool SupportsReverseEvents => true; + + /// /// /// [start, end) by event time (a reversed window is swapped), ordered by (ts, event_id) ascending. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs index 315009e4..dac74cb6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs @@ -311,6 +311,11 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource // ------------------------------------------------------------------------------------------ Events + /// + /// Reverse events reads are served natively (ORDER BY … DESC, Gitea #675). + public bool SupportsReverseEvents => true; + + /// /// /// [start, end) by EventTimeUtc (a reversed window is swapped), ordered by diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 3fdc8cfe..da3ef1cd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -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 cluster = (await _historianDataSource.ReadEventsAsync( + IReadOnlyList 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 diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataSourceReverseEventsGuardTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataSourceReverseEventsGuardTests.cs new file mode 100644 index 00000000..520ba3b0 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataSourceReverseEventsGuardTests.cs @@ -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; + +/// +/// Gitea #675 review: the reverse-events surface is two interface members with defaults +/// ( = false and the direction-aware +/// ReadEventsAsync(…, bool reverse, …)). 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 +/// BadHistoryOperationUnsupported on a backend that supports it. So every production implementation must declare +/// BOTH members itself. +/// +public sealed class HistorianDataSourceReverseEventsGuardTests +{ + private static IReadOnlyList 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(); } + }) + .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 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"); + } + } +} diff --git a/tests/Drivers/Historian.Conformance/EventReadConformance.cs b/tests/Drivers/Historian.Conformance/EventReadConformance.cs index 8d8110ca..5b35f530 100644 --- a/tests/Drivers/Historian.Conformance/EventReadConformance.cs +++ b/tests/Drivers/Historian.Conformance/EventReadConformance.cs @@ -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(); + 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() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs index 746e369d..5f8ad206 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs @@ -35,6 +35,7 @@ public sealed class GatewayEventBoundsTests { var fake = new FakeHistorianGatewayClient(); IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + ds.SupportsReverseEvents.ShouldBeFalse("the server must keep end-only reads forward on the default backend"); await Should.ThrowAsync(() => ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryPagingOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryPagingOverTheWireTests.cs index c3499953..c0a6701e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryPagingOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryPagingOverTheWireTests.cs @@ -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; + /// The #675 overload: reverse is (lo, hi] by (time, id) descending. public async Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken ct) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs index 8736fd0c..b0e69ec3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs @@ -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(); + } + + /// Declares the reverse overload (so a call would be visible) but reports no reverse capability. + 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 ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) + { + ForwardCalls++; + return inner.ReadEventsAsync(n, s, e, m, ct); + } + public Task 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 ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + inner.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + inner.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + inner.ReadAtTimeAsync(r, t, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => inner.GetHealthSnapshot(); + public void Dispose() { } + } + + /// Supports reverse, but its read fails inside with a library-level NotSupportedException. + private sealed class ThrowingReverseSource : IHistorianDataSource + { + public bool SupportsReverseEvents => true; + public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => + throw new NotSupportedException("driver: unsupported wire type"); + public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct) => + throw new NotSupportedException("driver: unsupported wire type"); + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } + + private sealed class CapturingLogger : ILogger + { + public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Entries.Enqueue((logLevel, exception, formatter(state, exception))); + } + /// A backend that implements only the five-argument (forward) event read - Gateway / Null shaped. 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 events, long resolution = 1) + List events, long resolution = 1, ILogger? 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.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 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 All { get; } =