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:
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user