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:
+2
@@ -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;
|
||||
|
||||
/// <summary>The #675 overload: reverse is <c>(lo, hi]</c> by (time, id) descending.</summary>
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken ct)
|
||||
|
||||
+101
-2
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Declares the reverse overload (so a call would be visible) but reports no reverse capability.</summary>
|
||||
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<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct)
|
||||
{
|
||||
ForwardCalls++;
|
||||
return inner.ReadEventsAsync(n, s, e, m, ct);
|
||||
}
|
||||
public Task<HistoricalEventsResult> 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<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
inner.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
inner.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
inner.ReadAtTimeAsync(r, t, ct);
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => inner.GetHealthSnapshot();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Supports reverse, but its read fails inside with a library-level NotSupportedException.</summary>
|
||||
private sealed class ThrowingReverseSource : IHistorianDataSource
|
||||
{
|
||||
public bool SupportsReverseEvents => true;
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
|
||||
throw new NotSupportedException("driver: unsupported wire type");
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct) =>
|
||||
throw new NotSupportedException("driver: unsupported wire type");
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger<OtOpcUaNodeManager>
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Enqueue((logLevel, exception, formatter(state, exception)));
|
||||
}
|
||||
|
||||
/// <summary>A backend that implements only the five-argument (forward) event read - Gateway / Null shaped.</summary>
|
||||
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<HistoricalEvent> events, long resolution = 1)
|
||||
List<HistoricalEvent> events, long resolution = 1, ILogger<OtOpcUaNodeManager>? 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<OpcUaApplicationHost>.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<HistoricalEvent> 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<HistoricalEvent> All { get; } =
|
||||
|
||||
Reference in New Issue
Block a user