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; } =