fix(historian): #675 review follow-ups — SupportsReverseEvents capability, no catch-as-unsupported

- IHistorianDataSource.SupportsReverseEvents (default false; SqlServer/QuestDB true; Gateway/Null declare false).
  The node manager checks it BEFORE calling the source: an end-only capped events read becomes reverse only on
  a supporting backend (Gateway, the default, keeps the pre-#675 forward [MinValue, EndTime) paging), and an
  explicit reverse read on a non-supporting backend is BadHistoryOperationUnsupported without a call.
- Removed the broad catch (NotSupportedException) in the events path: a NotSupportedException from inside a
  backend is now logged as a backend fault (Error), not an 'unsupported' Warning.
- Null declares the reverse overload itself; guard test: every production IHistorianDataSource declares both
  the reverse overload and the capability (positive-control scan of all four backends).
- Conformance: capability reported + oversized reverse tie cluster (forward over-fetch reversed == reverse
  read order; capped reverse read stopping inside the cluster flags truncation) on SqlServer + QuestDB.
- docs/Historian.md: end-only capped events read is backward on SqlServer/QuestDB, forward on Gateway.
This commit is contained in:
Joseph Doherty
2026-10-11 11:36:12 -04:00
parent 8e6ac4b344
commit 93d6c1da67
12 changed files with 290 additions and 27 deletions
@@ -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");
}
}
}