From 574c3aa1b0f4ee41bcfb873464b9e8be859c5252 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 17:37:32 -0400 Subject: [PATCH] =?UTF-8?q?fix(historian):=20#691.4=20review=20=E2=80=94?= =?UTF-8?q?=20Null=20reports=20SupportsReverseEvents=20=3D=20true,=20like?= =?UTF-8?q?=20raw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null source has no history in either direction; reporting false made the server walk an empty reverse events read back through three empty forward reads. It now answers the reverse overload with the empty result itself (guard test unchanged: it still declares both members). --- .../Historian/IHistorianDataSource.cs | 4 ++-- .../Historian/NullHistorianDataSource.cs | 13 +++++-------- .../OtOpcUaNodeManager.cs | 2 +- .../ReverseEventsReader.cs | 2 +- .../Historian/NullHistorianDataSourceTests.cs | 12 +++++++----- 5 files changed, 16 insertions(+), 17 deletions(-) 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 3b9c1b97..309a0b63 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 @@ -143,8 +143,8 @@ 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). The server never relies on that throw: it consults first and walks + /// for a reverse one — a backend that cannot read newest-first (the + /// Gateway). The server never relies on that throw: it consults first and walks /// such a read back from forward reads (#691.4), never returning the earliest events in ascending order as if /// they were the answer. A backend that serves reverse natively must override BOTH members. /// 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 9d97b317..811e3dc2 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 @@ -61,13 +61,12 @@ public sealed class NullHistorianDataSource : IHistorianDataSource public bool SupportsReverseRaw => true; /// - /// false (Gitea #675): there is no history to read in either direction; the server walks a reverse - /// read back from (empty) forward reads like any backend that cannot read newest-first (#691.4). - public bool SupportsReverseEvents => false; + /// true, like (Gitea #691.4 review; false under #675): there is no + /// history to read in either direction — no need for the server to walk back through empty forward reads. + public bool SupportsReverseEvents => true; /// - /// Forward ⇒ the empty result; reverse ⇒ , the interface contract for a - /// backend without . + /// The empty result in either direction. public Task ReadEventsAsync( string? sourceName, DateTime startUtc, @@ -75,9 +74,7 @@ public sealed class NullHistorianDataSource : IHistorianDataSource int maxEvents, bool reverse, CancellationToken cancellationToken) => - reverse - ? throw new NotSupportedException("The null historian cannot read events newest-first (reverse).") - : Task.FromResult(EmptyEvents); + Task.FromResult(EmptyEvents); /// /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 8488e171..a939ed3c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -5291,7 +5291,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 reverse = startUtc > endUtc; } - // A reverse read on a backend that cannot read newest-first (Gateway, Null) is never sent there (#675 refused it + // A reverse read on a backend that cannot read newest-first (the Gateway) is never sent there (#675 refused it // here); since #691.4 it is walked back from bounded forward reads — the raw walk (#686), with ties ordered by // EventId because the gateway orders by event time only. Decided by the capability, not by catching an // exception, so a NotSupportedException thrown from inside a backend stays a backend fault. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs index 52ab3bfd..f19648d3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs @@ -9,7 +9,7 @@ internal readonly record struct ReverseEventsPage(List Events, /// /// Gitea #691.4: serves a Part 11 reverse events read — (lo, hi] newest-first — on a backend that reads events -/// forward only ( = false: Gateway, Null) with the +/// forward only ( = false: the Gateway) with the /// . A segment read is cut short when the source flags truncation (its continuation point); ties /// are ordered by because the gateway orders by event time only. /// diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs index e9045e7b..4c4b1dd7 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs @@ -68,17 +68,19 @@ public sealed class NullHistorianDataSourceTests } /// - /// #675: the direction-aware overload's default — forward is the five-argument read, reverse is refused - /// (the server answers BadHistoryOperationUnsupported). + /// #691.4 review: like raw, the null source reads events in either direction (empty) and says so — the server sends + /// it the reverse read directly instead of walking back through empty forward reads (it refused reverse under #675). /// [Fact] - public async Task Direction_aware_ReadEventsAsync_forwards_forward_and_refuses_reverse() + public async Task Direction_aware_ReadEventsAsync_is_empty_in_both_directions() { IHistorianDataSource source = Source; + source.SupportsReverseEvents.ShouldBeTrue(); (await source.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 0, reverse: false, Ct)) .Events.ShouldBeEmpty(); - await Should.ThrowAsync(() => - source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct)); + var reverse = await source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct); + reverse.Events.ShouldBeEmpty(); + reverse.ContinuationPoint.ShouldBeNull(); } /// GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape.