efaf3435ea
v2-ci / unit-tests (push) Has been cancelled
A node declared numeric whose AVEVA tag is discrete (or string) is not refused up front by #897, so its native TimeAverage / Total still reach AVEVA, which refuses them. A HistorianGateway at or after historiangw#135 (a220ec0) answers that FailedPrecondition with a google.rpc.ErrorInfo (reason AGGREGATE_NOT_SUPPORTED_FOR_TAG, domain historian_gateway.v1) in grpc-status-details-bin. The Gateway backend's native processed read now maps exactly that to HistoryAggregateNotSupportedException (BadAggregateNotSupported for the node) without recording a health failure or treating it as transport. Any other FailedPrecondition and every Internal (an older gateway's answer for the same case) keep today's handling. Detection lives in one place, GatewayErrorReasons. The HistorianGateway.Client pin stays 1.0.0 (1.1.0 is not on the feed): 1.0.0 keeps the gateway's RpcException, trailers included, as the InnerException of the HistorianGatewayException it throws, so the reason is read with Grpc.StatusProto 2.76.0 (added via central package management; nuget.org; same version as Grpc.Core.Api; depends on Google.Api.CommonProtos 2.16.0, which accepts Google.Protobuf 3.34.1). With Client 1.1.0, ReasonOf becomes `(ex as HistorianGatewayException)?.ErrorReason` and the StatusProto reference can go. Tests: - GatewayAggregateNotSupportedWireTests: in-process gRPC gateway sending the real status + ErrorInfo shape (copied from historiangw HistorianRpcFaults.ToRpcException), read through the PUBLISHED Client 1.0.0 via the production adapter: TimeAverage / Total -> HistoryAggregateNotSupportedException, 0 health failures; controls (Internal; FailedPrecondition without ErrorInfo; other reason; other domain; the reason on Internal) are rethrown, not IOException, and counted; positive control served. - DiscreteTagTimeWeightedWireTests: over a real SDK session, a Double / Int32 node on the discrete tag -> BadAggregateNotSupported with 0 failures; the older gateway's Internal answer keeps its status and counts. - AvevaGatewayAggregateRefusalLiveGateTests: opt-in read-only live gate (OTOPCUA_AVEVA_GATE_900=true). Mutations (catch removed; status ignored; domain ignored; reason ignored; inner-exception walk removed) each fail at least one test. Release build 0 warnings; Historian.Gateway.Tests 601/607 (6 skipped), OpcUaServer IntegrationTests 323/361 (38 skipped). Live, read-only: private HistorianGateway container from a220ec0 (API-key auth disabled, live writes off, removed after) against wonder-sql-vd03: Double and Int32 nodes on SysPulse, TimeAverage and Total -> BadAggregateNotSupported, health failures unchanged (0); SysTimeSec TimeAverage Good (2 rows); SysPulse Average served. Gateway traffic was ReadAggregate / ReadRaw / GetTagMetadata only. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
211 lines
10 KiB
C#
211 lines
10 KiB
C#
using System.Net;
|
|
using Google.Protobuf;
|
|
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>
|
|
/// lmxopcua#900 on the wire: an in-process gRPC gateway answers <c>ReadAggregate</c> the way a HistorianGateway at or
|
|
/// after historiangw#135 (<c>a220ec0</c>) does when AVEVA refuses the retrieval mode for the tag —
|
|
/// <c>FailedPrecondition</c>, detail <c>HistorianAggregateNotSupportedException</c>, and a <c>google.rpc.Status</c> holding a
|
|
/// <c>google.rpc.ErrorInfo</c> (reason <c>AGGREGATE_NOT_SUPPORTED_FOR_TAG</c>, domain <c>historian_gateway.v1</c>, metadata
|
|
/// <c>retrieval_mode</c> + <c>native_error_code</c>) in <c>grpc-status-details-bin</c> (shape copied from the gateway's
|
|
/// <c>HistorianRpcFaults.ToRpcException</c>). Read through the PUBLISHED Client 1.0.0 (via the production adapter), so this
|
|
/// also pins that 1.0.0 keeps the trailer on the exception it throws. Controls: every other shape keeps today's handling
|
|
/// (rethrown, counted as a backend failure).
|
|
/// </summary>
|
|
public sealed class GatewayAggregateNotSupportedWireTests
|
|
{
|
|
private static readonly DateTime S0 = new(2026, 11, 13, 12, 0, 0, DateTimeKind.Utc);
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
[Theory]
|
|
[InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)]
|
|
[InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)]
|
|
public async Task The_gateways_aggregate_refusal_is_BadAggregateNotSupported_and_no_health_failure(
|
|
HistoryAggregateType aggregate, RetrievalMode expectedMode)
|
|
{
|
|
var read = new ReadService { Fault = Refusal };
|
|
await using var server = await StartServerAsync(read);
|
|
var (adapter, ds) = Create(server.Port);
|
|
using var _ = adapter;
|
|
|
|
var ex = await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
|
|
ds.ReadProcessedAsync("SysPulse", S0, S0.AddMinutes(4), TimeSpan.FromMinutes(2), aggregate, Ct));
|
|
|
|
ex.Aggregate.ShouldBe(aggregate);
|
|
read.Modes.ShouldBe([expectedMode], "the refusal came from the gateway, not up front");
|
|
var health = ds.GetHealthSnapshot();
|
|
health.TotalFailures.ShouldBe(0, "a tag that cannot serve the aggregate is configuration, not a backend failure");
|
|
health.ConsecutiveFailures.ShouldBe(0);
|
|
health.LastError.ShouldBeNull();
|
|
}
|
|
|
|
public static TheoryData<string> OrdinaryFailures() =>
|
|
[
|
|
"internal", // an older gateway / a genuine historian fault: Internal, no ErrorInfo
|
|
"failed-precondition-bare", // FailedPrecondition without ErrorInfo (e.g. a historian-side one passed through)
|
|
"failed-precondition-other", // FailedPrecondition with another reason
|
|
"failed-precondition-domain", // the reason string, but not the gateway's domain
|
|
"internal-with-reason", // the reason on a status that is not FailedPrecondition
|
|
];
|
|
|
|
[Theory]
|
|
[MemberData(nameof(OrdinaryFailures))]
|
|
public async Task Every_other_failure_shape_keeps_todays_handling_and_counts(string shape)
|
|
{
|
|
var read = new ReadService
|
|
{
|
|
Fault = shape switch
|
|
{
|
|
"internal" => () => new RpcException(new Status(StatusCode.Internal, "HistorianServerOperationException")),
|
|
"failed-precondition-bare" => () => new RpcException(new Status(StatusCode.FailedPrecondition, "HistorianServerRejectionException")),
|
|
"failed-precondition-other" => () => WithErrorInfo(StatusCode.FailedPrecondition, "LIVE_WRITES_DISABLED", "historian_gateway.v1"),
|
|
"failed-precondition-domain" => () => WithErrorInfo(StatusCode.FailedPrecondition, "AGGREGATE_NOT_SUPPORTED_FOR_TAG", "example.com"),
|
|
"internal-with-reason" => () => WithErrorInfo(StatusCode.Internal, "AGGREGATE_NOT_SUPPORTED_FOR_TAG", "historian_gateway.v1"),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(shape)),
|
|
},
|
|
};
|
|
await using var server = await StartServerAsync(read);
|
|
var (adapter, ds) = Create(server.Port);
|
|
using var _ = adapter;
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(() =>
|
|
ds.ReadProcessedAsync("SysPulse", S0, S0.AddMinutes(4), TimeSpan.FromMinutes(2), HistoryAggregateType.TimeAverage, Ct));
|
|
|
|
ex.ShouldNotBeOfType<HistoryAggregateNotSupportedException>();
|
|
ex.ShouldNotBeOfType<IOException>("a refusal is not a transport failure");
|
|
ds.GetHealthSnapshot().TotalFailures.ShouldBe(1, $"{shape} is a backend failure, as before #900");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Positive_control_a_served_aggregate_succeeds_over_the_same_server()
|
|
{
|
|
var read = new ReadService();
|
|
await using var server = await StartServerAsync(read);
|
|
var (adapter, ds) = Create(server.Port);
|
|
using var _ = adapter;
|
|
|
|
var result = await ds.ReadProcessedAsync("SysTimeSec", S0, S0.AddMinutes(4), TimeSpan.FromMinutes(2),
|
|
HistoryAggregateType.TimeAverage, Ct);
|
|
|
|
result.Samples.Count.ShouldBe(2);
|
|
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
|
ds.GetHealthSnapshot().TotalSuccesses.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
/// <summary>What a gateway at or after historiangw#135 sends (HistorianRpcFaults.ToRpcException).</summary>
|
|
private static RpcException Refusal() => new(
|
|
new Status(StatusCode.FailedPrecondition, "HistorianAggregateNotSupportedException"),
|
|
new Metadata
|
|
{
|
|
{
|
|
"grpc-status-details-bin",
|
|
new Google.Rpc.Status
|
|
{
|
|
Code = (int)StatusCode.FailedPrecondition,
|
|
Message = "HistorianAggregateNotSupportedException",
|
|
Details =
|
|
{
|
|
Any.Pack(new Google.Rpc.ErrorInfo
|
|
{
|
|
Reason = "AGGREGATE_NOT_SUPPORTED_FOR_TAG",
|
|
Domain = "historian_gateway.v1",
|
|
Metadata = { ["retrieval_mode"] = "RETRIEVAL_MODE_TIME_WEIGHTED_AVERAGE", ["native_error_code"] = "85" },
|
|
}),
|
|
},
|
|
}.ToByteArray()
|
|
},
|
|
});
|
|
|
|
private static RpcException WithErrorInfo(StatusCode code, string reason, string domain) => new(
|
|
new Status(code, "HistorianServerRejectionException"),
|
|
new Metadata
|
|
{
|
|
{
|
|
"grpc-status-details-bin",
|
|
new Google.Rpc.Status
|
|
{
|
|
Code = (int)code,
|
|
Message = "HistorianServerRejectionException",
|
|
Details = { Any.Pack(new Google.Rpc.ErrorInfo { Reason = reason, Domain = domain }) },
|
|
}.ToByteArray()
|
|
},
|
|
});
|
|
|
|
private static (HistorianGatewayClientAdapter Adapter, GatewayHistorianDataSource Ds) Create(int port)
|
|
{
|
|
var adapter = HistorianGatewayClientAdapter.Create(
|
|
new ServerHistorianOptions { Enabled = true, Endpoint = $"http://127.0.0.1:{port}", ApiKey = "histgw_test_key", UseTls = false },
|
|
NullLoggerFactory.Instance);
|
|
return (adapter, new GatewayHistorianDataSource(adapter, NullLogger<GatewayHistorianDataSource>.Instance));
|
|
}
|
|
|
|
private sealed class ReadService : HistorianRead.HistorianReadBase
|
|
{
|
|
private readonly List<RetrievalMode> _modes = [];
|
|
|
|
public Func<RpcException>? Fault { get; init; }
|
|
|
|
public IReadOnlyList<RetrievalMode> Modes { get { lock (_modes) return _modes.ToList(); } }
|
|
|
|
public override async Task ReadAggregate(ReadAggregateRequest request, IServerStreamWriter<HistorianAggregateSample> stream,
|
|
ServerCallContext context)
|
|
{
|
|
lock (_modes) _modes.Add(request.Mode);
|
|
if (Fault is not null) throw Fault();
|
|
var interval = request.Interval.ToTimeSpan();
|
|
for (var t = request.StartTime.ToDateTime(); t < request.EndTime.ToDateTime(); t += interval)
|
|
await stream.WriteAsync(new HistorianAggregateSample
|
|
{
|
|
Value = 29.5, OpcQuality = 192, StartTime = Timestamp.FromDateTime(t), EndTime = Timestamp.FromDateTime(t + interval),
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>Every tag exists (GetTagMetadata answers metadata), so the read reaches ReadAggregate.</summary>
|
|
private sealed class TagsService : HistorianTags.HistorianTagsBase
|
|
{
|
|
public override Task<GetTagMetadataReply> GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context) =>
|
|
Task.FromResult(new GetTagMetadataReply { Metadata = new HistorianTagMetadata { Name = request.Tag } });
|
|
}
|
|
|
|
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(ReadService read)
|
|
{
|
|
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(read);
|
|
builder.Services.AddSingleton<TagsService>();
|
|
var app = builder.Build();
|
|
app.MapGrpcService<ReadService>();
|
|
app.MapGrpcService<TagsService>();
|
|
await app.StartAsync();
|
|
return new Server(app, new Uri(app.Urls.First()).Port);
|
|
}
|
|
}
|