93d6c1da67
- 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.
125 lines
5.2 KiB
C#
125 lines
5.2 KiB
C#
using System.Net;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using Grpc.Core;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #675: the Gateway backend's event-read bounds and direction, offline.
|
|
/// <list type="bullet">
|
|
/// <item>A reverse (newest-first) read is refused before anything is sent — the gateway streams ascending only.</item>
|
|
/// <item>The open upper bound a start-only forward read uses (<c>DateTime.MaxValue</c>, UTC kind) survives the
|
|
/// PUBLISHED client package's proto conversion — captured on the wire by an in-process gRPC server — and the
|
|
/// Windows FILETIME encoding the gateway converts it to. The live AVEVA leg (what the historian does with a
|
|
/// year-9999 bound) stays open: it needs the VPN.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public sealed class GatewayEventBoundsTests
|
|
{
|
|
private static readonly DateTime UtcMax = new(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
[Fact]
|
|
public async Task A_reverse_events_read_is_refused_and_sends_nothing()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
ds.SupportsReverseEvents.ShouldBeFalse("the server must keep end-only reads forward on the default backend");
|
|
|
|
await Should.ThrowAsync<NotSupportedException>(() =>
|
|
ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct));
|
|
fake.ReadEventsCallCount.ShouldBe(0);
|
|
|
|
// Forward through the same overload is the five-argument read, unchanged.
|
|
await ds.ReadEventsAsync("Pump1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10, reverse: false, Ct);
|
|
fake.ReadEventsCallCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void UtcMax_round_trips_through_the_proto_Timestamp_and_FILETIME_encodings()
|
|
{
|
|
var ts = Timestamp.FromDateTime(UtcMax);
|
|
ts.ToDateTime().ShouldBe(UtcMax);
|
|
ts.ToDateTime().Kind.ShouldBe(DateTimeKind.Utc);
|
|
|
|
// The gateway hands its bounds to the historian as FILETIME (100 ns since 1601) — in range up to year 30828.
|
|
var fileTime = ts.ToDateTime().ToFileTimeUtc();
|
|
DateTime.FromFileTimeUtc(fileTime).ShouldBe(UtcMax);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_start_only_bound_of_UtcMax_reaches_the_wire_intact_through_the_published_client()
|
|
{
|
|
var capture = new CapturingReadService();
|
|
await using var server = await StartServerAsync(capture);
|
|
using var adapter = HistorianGatewayClientAdapter.Create(
|
|
new ServerHistorianOptions
|
|
{
|
|
Enabled = true,
|
|
Endpoint = $"http://127.0.0.1:{server.Port}",
|
|
ApiKey = "histgw_test_key",
|
|
UseTls = false,
|
|
},
|
|
NullLoggerFactory.Instance);
|
|
IHistorianDataSource ds = new GatewayHistorianDataSource(adapter, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
|
|
var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var r = await ds.ReadEventsAsync("Pump1", start, UtcMax, 5, Ct);
|
|
|
|
r.Events.ShouldBeEmpty();
|
|
capture.Request.ShouldNotBeNull();
|
|
capture.Request!.StartTime.ToDateTime().ShouldBe(start);
|
|
capture.Request.EndTime.ToDateTime().ShouldBe(UtcMax);
|
|
DateTime.FromFileTimeUtc(capture.Request.EndTime.ToDateTime().ToFileTimeUtc()).ShouldBe(UtcMax);
|
|
}
|
|
|
|
private sealed class CapturingReadService : HistorianRead.HistorianReadBase
|
|
{
|
|
public ReadEventsRequest? Request { get; private set; }
|
|
|
|
public override Task ReadEvents(
|
|
ReadEventsRequest request, IServerStreamWriter<HistorianEvent> responseStream, ServerCallContext context)
|
|
{
|
|
Request = request;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class Server(WebApplication app, int port) : IAsyncDisposable
|
|
{
|
|
public int Port { get; } = port;
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await app.StopAsync();
|
|
await app.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
private static async Task<Server> StartServerAsync(CapturingReadService service)
|
|
{
|
|
var builder = WebApplication.CreateSlimBuilder();
|
|
builder.Logging.ClearProviders();
|
|
builder.WebHost.ConfigureKestrel(k =>
|
|
k.Listen(IPAddress.Loopback, 0, o => o.Protocols = HttpProtocols.Http2));
|
|
builder.Services.AddGrpc();
|
|
builder.Services.AddSingleton(service);
|
|
var app = builder.Build();
|
|
app.MapGrpcService<CapturingReadService>();
|
|
await app.StartAsync();
|
|
var port = new Uri(app.Urls.First()).Port;
|
|
return new Server(app, port);
|
|
}
|
|
}
|