8d154c6a81
- IHistorianDataSource.ReadEventsAsync(..., bool reverse, ...) overload: reverse = Part 11 window (lo, hi] newest-first by (time, id) DESC. Default forwards forward reads and throws NotSupportedException for reverse (Null); Gateway refuses explicitly (ascending-only stream). Server answers BadHistoryOperationUnsupported instead of silently returning the earliest N ascending. - SqlServer / QuestDB serve reverse natively (ORDER BY time DESC, id DESC + TOP/LIMIT). - Node manager: reverse event reads page with continuation points; direction stored in the cursor (HistoryContinuationState.Reverse) so a resume continues in the cursor's direction; reverse tie clusters over-fetched forward and sliced from the newest end (SliceTieCluster(reverse:)); end-only reads with a cap page backward from EndTime (mirrors raw). - Resumed event pages over-fetch by the boundary skip, so every non-final page is a full cap page. - Tests: node-manager reverse paging (ties, clusters, coarse resolution, overfetch bound, resume direction both ways, end-only, unsupported backend); shared EventReadConformance on SqlServer + QuestDB; over-the-wire reverse paging; offline Gateway pin of the UTC DateTime.MaxValue bound through the published client's proto conversion (in-process gRPC capture) and FILETIME. - docs/Historian.md event paging section.
124 lines
5.1 KiB
C#
124 lines
5.1 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);
|
|
|
|
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);
|
|
}
|
|
}
|