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
112 lines
6.3 KiB
C#
112 lines
6.3 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Live gate for lmxopcua#900: a node declared numeric whose AVEVA tag is discrete (<c>SysPulse</c>) is not refused up
|
|
/// front (#897 refuses only Boolean nodes), so its native TimeAverage / Total reach AVEVA, which refuses them. A
|
|
/// HistorianGateway at or after historiangw#135 (<c>a220ec0</c>) answers FailedPrecondition + ErrorInfo
|
|
/// <c>AGGREGATE_NOT_SUPPORTED_FOR_TAG</c>: the booted server with the <b>production</b> Gateway backend must answer
|
|
/// <c>BadAggregateNotSupported</c> for the node and count no backend failure.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Read-only:</b> HistoryRead only; the Gateway backend has no updater and no provisioner / recorder is built, so no
|
|
/// tag is created and nothing is written. <b>Env:</b> <c>HISTGW_GATEWAY_ENDPOINT</c> + <c>HISTGW_GATEWAY_APIKEY</c> (any value
|
|
/// against a gateway with API-key auth disabled) AND <c>OTOPCUA_AVEVA_GATE_900=true</c> (an older gateway still answers
|
|
/// Internal, which this gate would report) — skips otherwise.
|
|
/// </remarks>
|
|
public sealed class AvevaGatewayAggregateRefusalLiveGateTests
|
|
{
|
|
private const string SysPulse = "SysPulse";
|
|
private const string SysTimeSec = "SysTimeSec";
|
|
private static readonly DateTime S0 = new(2026, 11, 11, 14, 0, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public async Task A_numeric_node_on_a_discrete_AVEVA_tag_answers_BadAggregateNotSupported_without_a_health_failure()
|
|
{
|
|
var endpoint = Env("HISTGW_GATEWAY_ENDPOINT");
|
|
var apiKey = Env("HISTGW_GATEWAY_APIKEY");
|
|
if (endpoint is null || apiKey is null || !string.Equals(Env("OTOPCUA_AVEVA_GATE_900"), "true", StringComparison.OrdinalIgnoreCase))
|
|
Assert.Skip("Set HISTGW_GATEWAY_ENDPOINT, HISTGW_GATEWAY_APIKEY and OTOPCUA_AVEVA_GATE_900=true (gateway at/after a220ec0).");
|
|
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var options = new ServerHistorianOptions
|
|
{
|
|
Backend = "Gateway", Enabled = true, Endpoint = endpoint!, ApiKey = apiKey!,
|
|
UseTls = endpoint!.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
|
|
};
|
|
var provider = HistorianBackendRegistry.Resolve(options, new ConfigurationBuilder().Build());
|
|
provider.CreateUpdater(new ServiceCollection().BuildServiceProvider()).ShouldBeNull("read-only gate");
|
|
using var source = provider.CreateDataSource(new ServiceCollection().BuildServiceProvider());
|
|
|
|
await using var rig = await StartAsync("AvevaGate900", encrypted: false, ct);
|
|
rig.Nm.HistorianDataSource = source;
|
|
rig.Enforce(false, [Key()]);
|
|
var dblOnPulse = rig.EnsureRawVariable("HistDblOnPulse", "Double", writable: false, SysPulse);
|
|
var intOnPulse = rig.EnsureRawVariable("HistIntOnPulse", "Int32", writable: false, SysPulse);
|
|
var dblOnSec = rig.EnsureRawVariable("HistDblOnSec", "Double", writable: false, SysTimeSec);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
|
|
// Positive control first: the gateway is up and serves the native mode on an analog tag.
|
|
var (secStatus, secRows) = await ReadProcessedAsync(session, new NodeId(dblOnSec, ns), ObjectIds.AggregateFunction_TimeAverage, ct);
|
|
secStatus.Code.ShouldBe(StatusCodes.Good.Code, "SysTimeSec TimeAverage is served");
|
|
secRows.ShouldBe(2);
|
|
var before = source.GetHealthSnapshot();
|
|
before.TotalFailures.ShouldBe(0);
|
|
|
|
foreach (var node in new[] { dblOnPulse, intOnPulse })
|
|
foreach (var aggregate in new[] { ObjectIds.AggregateFunction_TimeAverage, ObjectIds.AggregateFunction_Total })
|
|
{
|
|
var (status, rows) = await ReadProcessedAsync(session, new NodeId(node, ns), aggregate, ct);
|
|
status.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code, $"{aggregate} on {node} (SysPulse)");
|
|
rows.ShouldBe(0);
|
|
}
|
|
|
|
// Directly on the backend: the refusal comes from AVEVA via the gateway (not up front — no type hint here).
|
|
await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() => source.ReadProcessedAsync(
|
|
SysPulse, S0, S0.AddMinutes(4), TimeSpan.FromMinutes(2), HistoryAggregateType.TimeAverage, ct));
|
|
|
|
var after = source.GetHealthSnapshot();
|
|
after.TotalFailures.ShouldBe(before.TotalFailures, "the tag's configuration is not a backend failure");
|
|
after.ConsecutiveFailures.ShouldBe(0);
|
|
after.LastError.ShouldBeNull();
|
|
|
|
// Control: the raw-evaluated Average on the same node is still served (0/1).
|
|
var (avgStatus, avgRows) = await ReadProcessedAsync(session, new NodeId(dblOnPulse, ns), ObjectIds.AggregateFunction_Average, ct);
|
|
avgStatus.Code.ShouldBe(StatusCodes.Good.Code);
|
|
avgRows.ShouldBe(2);
|
|
}
|
|
|
|
private static async Task<(StatusCode Status, int Rows)> ReadProcessedAsync(ISession session, NodeId node, NodeId aggregate,
|
|
CancellationToken ct)
|
|
{
|
|
var details = new ReadProcessedDetails
|
|
{
|
|
StartTime = S0, EndTime = S0.AddMinutes(4), ProcessingInterval = 120_000,
|
|
AggregateType = new List<NodeId> { aggregate },
|
|
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
|
};
|
|
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
|
new HistoryReadValueId[] { new() { NodeId = node } }, ct);
|
|
var result = response.Results.ToList().ShouldHaveSingleItem();
|
|
var rows = !result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryData data
|
|
? data.DataValues.Count
|
|
: 0;
|
|
return (result.StatusCode, rows);
|
|
}
|
|
|
|
private static string? Env(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v.Trim() : null;
|
|
}
|