fix(historian): gateway AGGREGATE_NOT_SUPPORTED_FOR_TAG -> BadAggregateNotSupported, no health failure (#900)
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
This commit is contained in:
Joseph Doherty
2026-11-13 01:31:52 -05:00
parent 6c4d29902e
commit efaf3435ea
8 changed files with 495 additions and 6 deletions
@@ -0,0 +1,72 @@
using Grpc.Core;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// <summary>
/// The HistorianGateway's machine-readable failure reasons (historiangw#135): a <c>google.rpc.ErrorInfo</c> the gateway
/// attaches in the standard <c>grpc-status-details-bin</c> trailer, domain <see cref="Domain"/>. The ONE place OtOpcUa
/// reads them (lmxopcua#900).
/// </summary>
/// <remarks>
/// The Client pin is 1.0.0, which does not surface the reason, but keeps the gateway's <see cref="RpcException"/> —
/// trailers included — as the <see cref="Exception.InnerException"/> of the <c>HistorianGatewayException</c> it throws, so
/// <see cref="ReasonOf"/> parses the trailer with <c>Grpc.StatusProto</c>. When the pin moves to Client 1.1.0 (not yet on the
/// feed), <see cref="ReasonOf"/> becomes the one line
/// <c>(ex as ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException)?.ErrorReason</c> (and the constants can point
/// at <c>HistorianGatewayErrorReasons</c>); the <c>Grpc.StatusProto</c> reference can then go.
/// </remarks>
internal static class GatewayErrorReasons
{
/// <summary>The <c>ErrorInfo.domain</c> of every reason the gateway sends (its proto package).</summary>
public const string Domain = "historian_gateway.v1";
/// <summary>
/// AVEVA refused the aggregate retrieval mode for this tag (TimeWeightedAverage / Integral / … on a discrete or string
/// tag; native CustomError 85 at StartQuery). Sent with <see cref="StatusCode.FailedPrecondition"/>.
/// </summary>
public const string AggregateNotSupportedForTag = "AGGREGATE_NOT_SUPPORTED_FOR_TAG";
/// <summary>
/// The gateway's <c>ErrorInfo.reason</c> on <paramref name="ex"/> (or an inner exception — the client package wraps the
/// <see cref="RpcException"/>), or <see langword="null"/> when it sent none, sent one from another domain, or sent an
/// unparseable trailer.
/// </summary>
/// <param name="ex">A gateway call's exception.</param>
/// <returns>The reason string, or <see langword="null"/>.</returns>
public static string? ReasonOf(Exception ex)
{
for (var e = ex; e is not null; e = e.InnerException)
{
if (e is not RpcException rpc) continue;
try
{
var info = rpc.GetRpcStatus()?.GetDetail<Google.Rpc.ErrorInfo>();
return info is not null && string.Equals(info.Domain, Domain, StringComparison.Ordinal) ? info.Reason : null;
}
catch (Google.Protobuf.InvalidProtocolBufferException)
{
return null; // a malformed trailer is no reason: the failure keeps its ordinary handling
}
}
return null;
}
/// <summary>
/// True when <paramref name="ex"/> is the gateway's refusal of an aggregate for the tag: status
/// <see cref="StatusCode.FailedPrecondition"/> AND reason <see cref="AggregateNotSupportedForTag"/>. FailedPrecondition alone
/// is not enough — the gateway also uses it for disabled live writes and passes historian-side ones through — and an
/// older gateway answers this case <c>Internal</c> with no reason (unchanged handling).
/// </summary>
/// <param name="ex">A gateway call's exception.</param>
/// <returns>Whether it is the aggregate refusal.</returns>
public static bool IsAggregateNotSupportedForTag(Exception ex) =>
StatusOf(ex) == StatusCode.FailedPrecondition
&& string.Equals(ReasonOf(ex), AggregateNotSupportedForTag, StringComparison.Ordinal);
private static StatusCode? StatusOf(Exception ex)
{
for (var e = ex; e is not null; e = e.InnerException)
if (e is RpcException rpc) return rpc.StatusCode;
return null;
}
}
@@ -497,7 +497,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
if (!_enableNativeTimeWeighted) throw new HistoryAggregateNotSupportedException(aggregate);
// Planned OUTSIDE the failure-recording try: too many intervals is a client request limit, not a backend fault.
return await ReadNativeAsync(fullReference, BucketPlan.Create(startUtc, endUtc, interval), mode, cancellationToken)
return await ReadNativeAsync(fullReference, BucketPlan.Create(startUtc, endUtc, interval), aggregate, mode, cancellationToken)
.ConfigureAwait(false);
}
@@ -505,8 +505,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
/// A native TimeAverage / Total read on the SQL backends' interval grid, either direction: forward native
/// requests only, the clipped interval read over exactly its span. See <see cref="NativeAggregatePlan"/>.
/// </summary>
private async Task<HistoryReadResult> ReadNativeAsync(string fullReference, BucketPlan plan, RetrievalMode mode,
CancellationToken cancellationToken)
private async Task<HistoryReadResult> ReadNativeAsync(string fullReference, BucketPlan plan, HistoryAggregateType aggregate,
RetrievalMode mode, CancellationToken cancellationToken)
{
if (plan.BucketCount == 0) return new HistoryReadResult([], ContinuationPoint: null);
// Planned OUTSIDE the failure-recording try: a non-whole-ms interval over too many intervals is a client request limit.
@@ -538,6 +538,17 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
{
throw; // caller-requested, not a backend fault
}
catch (Exception ex) when (GatewayErrorReasons.IsAggregateNotSupportedForTag(ex))
{
// lmxopcua#900: AVEVA refused this retrieval mode for the tag (a node declared numeric whose AVEVA tag is discrete
// or string — the residual of #897's up-front Boolean refusal), answered by a gateway at or after historiangw#135
// as FailedPrecondition + ErrorInfo AGGREGATE_NOT_SUPPORTED_FOR_TAG. It is the tag's configuration, not a backend
// fault: BadAggregateNotSupported for the node, no health failure, not transport. An older gateway answers the same
// case Internal with no reason, which still falls through to the generic handler below.
_logger.LogDebug("HistorianGateway refused the {Aggregate} aggregate for the tag (AGGREGATE_NOT_SUPPORTED_FOR_TAG).",
aggregate);
throw new HistoryAggregateNotSupportedException(aggregate);
}
catch (Exception ex)
{
RecordReadFailure(ex);
@@ -25,6 +25,8 @@
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.HistorianGateway.Client" />
<PackageReference Include="ZB.MOM.WW.HistorianGateway.Contracts" />
<!-- lmxopcua#900: GatewayErrorReasons reads the gateway's ErrorInfo trailer (until the Client 1.1.0 pin). -->
<PackageReference Include="Grpc.StatusProto" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<!-- FasterLog-backed durable historization outbox (Recorder/FasterLogHistorizationOutbox).
Pure-managed FasterLog; same package the gateway's store-forward outbox uses. -->