chore(historian): HistorianGateway Client/Contracts 1.1.0, typed error reason (#900)
v2-ci / unit-tests (push) Has been cancelled

Bump ZB.MOM.WW.HistorianGateway.Client + Contracts 1.0.0 -> 1.1.0 (historiangw#135,
release 6365f34). GatewayErrorReasons now reads Client 1.1.0's typed
HistorianGatewayException.StatusCode / ErrorReason / ErrorDomain (constants from
HistorianGatewayErrorReasons) instead of parsing grpc-status-details-bin itself; the
direct Grpc.StatusProto pin and reference are dropped (the client brings it). The
published client maps every RpcException to a HistorianGatewayException, so the
trailer fallback is not needed. Behaviour unchanged: FailedPrecondition +
AGGREGATE_NOT_SUPPORTED_FOR_TAG (domain historian_gateway.v1) ->
BadAggregateNotSupported, everything else keeps its handling.

Adds GatewayErrorReasonsTests (typed-property-only exceptions, so they fail against
the old trailer implementation); wire tests and docs/Historian.md updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 07:17:10 -05:00
parent 97d513f7dc
commit 76e73e7dbf
7 changed files with 98 additions and 61 deletions
+2 -5
View File
@@ -32,9 +32,6 @@
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" /> <PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageVersion Include="Grpc.Core.Api" Version="2.76.0" /> <PackageVersion Include="Grpc.Core.Api" Version="2.76.0" />
<PackageVersion Include="Grpc.Net.Client" Version="2.76.0" /> <PackageVersion Include="Grpc.Net.Client" Version="2.76.0" />
<!-- lmxopcua#900: reads the HistorianGateway's google.rpc.ErrorInfo from grpc-status-details-bin while the
Client pin is 1.0.0 (Client 1.1.0 exposes it as HistorianGatewayException.ErrorReason). Same version as Grpc.Core.Api. -->
<PackageVersion Include="Grpc.StatusProto" Version="2.76.0" />
<PackageVersion Include="Grpc.Tools" Version="2.76.0" /> <PackageVersion Include="Grpc.Tools" Version="2.76.0" />
<PackageVersion Include="libplctag" Version="1.5.2" /> <PackageVersion Include="libplctag" Version="1.5.2" />
<PackageVersion Include="MessagePack" Version="2.5.301" /> <PackageVersion Include="MessagePack" Version="2.5.301" />
@@ -245,7 +242,7 @@
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.6.2" /> <PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.6.2" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.5.0" /> <PackageVersion Include="ZB.MOM.WW.Theme" Version="0.5.0" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.0.0" /> <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.1.0" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.0.0" /> <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+7 -5
View File
@@ -345,7 +345,7 @@ and is registered the same way for every backend.
> **Cross-repo follow-up — a multi-tag batch `WriteLiveValues` on the gateway contract.** The drain's > **Cross-repo follow-up — a multi-tag batch `WriteLiveValues` on the gateway contract.** The drain's
> unit of work is one gateway round-trip **per historian tag**, because > unit of work is one gateway round-trip **per historian tag**, because
> `ZB.MOM.WW.HistorianGateway.Client` **1.0.0** exposes no RPC that writes values for more than one tag > `ZB.MOM.WW.HistorianGateway.Client` **1.1.0** exposes no RPC that writes values for more than one tag
> in a call. Bounded parallelism (`DrainConcurrency`) is therefore the ceiling: it overlaps the > in a call. Bounded parallelism (`DrainConcurrency`) is therefore the ceiling: it overlaps the
> round-trips but cannot remove them, so a wide tag set still pays `⌈tags / concurrency⌉` network > round-trips but cannot remove them, so a wide tag set still pays `⌈tags / concurrency⌉` network
> hops per drain pass. Collapsing that to a single hop needs a **batch/multi-tag `WriteLiveValues`** on > hops per drain pass. Collapsing that to a single hop needs a **batch/multi-tag `WriteLiveValues`** on
@@ -353,8 +353,9 @@ and is registered the same way for every backend.
> lands, raising `DrainBatchSize` widens each hop's payload but not the hop count — which is why the > lands, raising `DrainBatchSize` widens each hop's payload but not the hop count — which is why the
> concurrency window, not the batch width, is what governs drain throughput today. > concurrency window, not the batch width, is what governs drain throughput today.
The HistorianGateway **Client and Contracts packages are pinned together at 1.0.0** The HistorianGateway **Client and Contracts packages are pinned together at 1.1.0**
([historiangw#131](https://gitea.dohertylan.com/dohertj2/historiangw/issues/131)). The adapter maps ([historiangw#135](https://gitea.dohertylan.com/dohertj2/historiangw/issues/135): additive — typed error reasons on
`HistorianGatewayException`; 1.0.0 before it, [historiangw#131](https://gitea.dohertylan.com/dohertj2/historiangw/issues/131)). The adapter maps
`AllowUntrustedServerCertificate` directly and only for TLS: the default verifies the server, while an `AllowUntrustedServerCertificate` directly and only for TLS: the default verifies the server, while an
explicit opt-out applies only when `UseTls=true`. Stale certificate options are ignored on plaintext h2c. explicit opt-out applies only when `UseTls=true`. Stale certificate options are ignored on plaintext h2c.
This follows the earlier [0.4.0 update](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119). The removed This follows the earlier [0.4.0 update](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119). The removed
@@ -1598,8 +1599,9 @@ after historiangw#135 (`a220ec0`) answers it `FailedPrecondition` with a `google
to **`BadAggregateNotSupported`** for the node and counts **no** backend failure (#900, live-verified read-only against to **`BadAggregateNotSupported`** for the node and counts **no** backend failure (#900, live-verified read-only against
wonder-sql-vd03 `SysPulse`). Only that reason is matched: any other `FailedPrecondition` and every `Internal` keep the old wonder-sql-vd03 `SysPulse`). Only that reason is matched: any other `FailedPrecondition` and every `Internal` keep the old
handling, so an older gateway (which answers this case `Internal`) still gives `BadHistoryOperationUnsupported`, counted as a handling, so an older gateway (which answers this case `Internal`) still gives `BadHistoryOperationUnsupported`, counted as a
failure. The reason is read in one place, `GatewayErrorReasons` — from the trailer via `Grpc.StatusProto` while the failure. The reason is read in one place, `GatewayErrorReasons`, from Client 1.1.0's typed
HistorianGateway Client pin is 1.0.0; with Client 1.1.0 it becomes `HistorianGatewayException.ErrorReason`. `HistorianGatewayException.StatusCode` / `ErrorReason` / `ErrorDomain` (the client parses the trailer; OtOpcUa no longer
references `Grpc.StatusProto` itself).
⚠️ **`Server/ServerCapabilities/AggregateFunctions` (`i=2997`) is deliberately NOT trimmed** — it keeps the SDK's full 37 in ⚠️ **`Server/ServerCapabilities/AggregateFunctions` (`i=2997`) is deliberately NOT trimmed** — it keeps the SDK's full 37 in
every configuration, including with no historian. Part 5 defines it as every aggregate the *server* supports, and the server every configuration, including with no historian. Part 5 defines it as every aggregate the *server* supports, and the server
@@ -1,72 +1,48 @@
using Grpc.Core; using Grpc.Core;
using ZB.MOM.WW.HistorianGateway.Client;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// <summary> /// <summary>
/// The HistorianGateway's machine-readable failure reasons (historiangw#135): a <c>google.rpc.ErrorInfo</c> the gateway /// 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 /// attaches in the standard <c>grpc-status-details-bin</c> trailer, domain <see cref="HistorianGatewayErrorReasons.Domain"/>.
/// reads them (lmxopcua#900). /// The ONE place OtOpcUa reads them (lmxopcua#900).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The Client pin is 1.0.0, which does not surface the reason, but keeps the gateway's <see cref="RpcException"/> — /// Client 1.1.0 parses the trailer itself and exposes it on every <see cref="HistorianGatewayException"/> it throws
/// trailers included — as the <see cref="Exception.InnerException"/> of the <c>HistorianGatewayException</c> it throws, so /// (<see cref="HistorianGatewayException.StatusCode"/>, <see cref="HistorianGatewayException.ErrorReason"/>,
/// <see cref="ReasonOf"/> parses the trailer with <c>Grpc.StatusProto</c>. When the pin moves to Client 1.1.0 (not yet on the /// <see cref="HistorianGatewayException.ErrorDomain"/>), so this reads those typed properties. The 1.0.0-era trailer
/// feed), <see cref="ReasonOf"/> becomes the one line /// parsing (<c>Grpc.StatusProto</c> over the inner <see cref="RpcException"/>) is gone: the published client maps every
/// <c>(ex as ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException)?.ErrorReason</c> (and the constants can point /// <see cref="RpcException"/> of every call to a <see cref="HistorianGatewayException"/> (or an
/// at <c>HistorianGatewayErrorReasons</c>); the <c>Grpc.StatusProto</c> reference can then go. /// <see cref="OperationCanceledException"/> for a caller cancellation), so no raw one reaches the data source.
/// </remarks> /// </remarks>
internal static class GatewayErrorReasons 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> /// <summary>
/// AVEVA refused the aggregate retrieval mode for this tag (TimeWeightedAverage / Integral / … on a discrete or string /// The gateway's <c>ErrorInfo.reason</c> on <paramref name="ex"/> (or an inner exception), or <see langword="null"/>
/// tag; native CustomError 85 at StartQuery). Sent with <see cref="StatusCode.FailedPrecondition"/>. /// when it sent none or sent one from another domain.
/// </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> /// </summary>
/// <param name="ex">A gateway call's exception.</param> /// <param name="ex">A gateway call's exception.</param>
/// <returns>The reason string, or <see langword="null"/>.</returns> /// <returns>The reason string, or <see langword="null"/>.</returns>
public static string? ReasonOf(Exception ex) public static string? ReasonOf(Exception ex) =>
{ GatewayExceptionOf(ex) is { ErrorDomain: HistorianGatewayErrorReasons.Domain } gw ? gw.ErrorReason : null;
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> /// <summary>
/// True when <paramref name="ex"/> is the gateway's refusal of an aggregate for the tag: status /// 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 /// <see cref="StatusCode.FailedPrecondition"/> AND reason <see cref="HistorianGatewayErrorReasons.AggregateNotSupportedForTag"/>.
/// is not enough — the gateway also uses it for disabled live writes and passes historian-side ones through — and an /// FailedPrecondition alone is not enough — the gateway also uses it for disabled live writes and passes historian-side
/// older gateway answers this case <c>Internal</c> with no reason (unchanged handling). /// ones through — and an older gateway answers this case <c>Internal</c> with no reason (unchanged handling).
/// </summary> /// </summary>
/// <param name="ex">A gateway call's exception.</param> /// <param name="ex">A gateway call's exception.</param>
/// <returns>Whether it is the aggregate refusal.</returns> /// <returns>Whether it is the aggregate refusal.</returns>
public static bool IsAggregateNotSupportedForTag(Exception ex) => public static bool IsAggregateNotSupportedForTag(Exception ex) =>
StatusOf(ex) == StatusCode.FailedPrecondition GatewayExceptionOf(ex)?.StatusCode == StatusCode.FailedPrecondition
&& string.Equals(ReasonOf(ex), AggregateNotSupportedForTag, StringComparison.Ordinal); && string.Equals(ReasonOf(ex), HistorianGatewayErrorReasons.AggregateNotSupportedForTag, StringComparison.Ordinal);
private static StatusCode? StatusOf(Exception ex) private static HistorianGatewayException? GatewayExceptionOf(Exception ex)
{ {
for (var e = ex; e is not null; e = e.InnerException) for (var e = ex; e is not null; e = e.InnerException)
if (e is RpcException rpc) return rpc.StatusCode; if (e is HistorianGatewayException gw) return gw;
return null; return null;
} }
} }
@@ -25,8 +25,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="ZB.MOM.WW.HistorianGateway.Client" /> <PackageReference Include="ZB.MOM.WW.HistorianGateway.Client" />
<PackageReference Include="ZB.MOM.WW.HistorianGateway.Contracts" /> <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" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<!-- FasterLog-backed durable historization outbox (Recorder/FasterLogHistorizationOutbox). <!-- FasterLog-backed durable historization outbox (Recorder/FasterLogHistorizationOutbox).
Pure-managed FasterLog; same package the gateway's store-forward outbox uses. --> Pure-managed FasterLog; same package the gateway's store-forward outbox uses. -->
@@ -22,8 +22,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <c>FailedPrecondition</c>, detail <c>HistorianAggregateNotSupportedException</c>, and a <c>google.rpc.Status</c> holding a /// <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>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>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 /// <c>HistorianRpcFaults.ToRpcException</c>). Read through the PUBLISHED Client 1.1.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 /// also pins that 1.1.0 surfaces the trailer as the typed <c>HistorianGatewayException.ErrorReason</c> /
/// <c>ErrorDomain</c> / <c>StatusCode</c> that <c>GatewayErrorReasons</c> reads. Controls: every other shape keeps today's handling
/// (rethrown, counted as a backend failure). /// (rethrown, counted as a backend failure).
/// </summary> /// </summary>
public sealed class GatewayAggregateNotSupportedWireTests public sealed class GatewayAggregateNotSupportedWireTests
@@ -0,0 +1,63 @@
using Grpc.Core;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Client;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// lmxopcua#900 on Client 1.1.0: <see cref="GatewayErrorReasons"/> classifies by the client's typed
/// <see cref="HistorianGatewayException.StatusCode"/> / <see cref="HistorianGatewayException.ErrorReason"/> /
/// <see cref="HistorianGatewayException.ErrorDomain"/>. These exceptions carry the reason ONLY on the typed properties
/// (no inner <see cref="RpcException"/>, no trailer), so they pin that the typed accessor is what is read. The on-the-wire
/// path through the published client is <see cref="GatewayAggregateNotSupportedWireTests"/>.
/// </summary>
public sealed class GatewayErrorReasonsTests
{
private static HistorianGatewayException Gw(StatusCode? code, string? reason, string? domain) =>
new("x") { StatusCode = code, ErrorReason = reason, ErrorDomain = domain };
[Fact]
public void FailedPrecondition_with_the_gateways_aggregate_reason_is_the_refusal()
{
var ex = Gw(StatusCode.FailedPrecondition, HistorianGatewayErrorReasons.AggregateNotSupportedForTag, HistorianGatewayErrorReasons.Domain);
GatewayErrorReasons.IsAggregateNotSupportedForTag(ex).ShouldBeTrue();
GatewayErrorReasons.ReasonOf(ex).ShouldBe("AGGREGATE_NOT_SUPPORTED_FOR_TAG");
}
[Fact]
public void The_refusal_is_found_when_wrapped()
{
var ex = new InvalidOperationException("outer",
Gw(StatusCode.FailedPrecondition, HistorianGatewayErrorReasons.AggregateNotSupportedForTag, HistorianGatewayErrorReasons.Domain));
GatewayErrorReasons.IsAggregateNotSupportedForTag(ex).ShouldBeTrue();
}
public static TheoryData<StatusCode?, string?, string?> NotTheRefusal() => new()
{
{ StatusCode.Internal, HistorianGatewayErrorReasons.AggregateNotSupportedForTag, HistorianGatewayErrorReasons.Domain },
{ null, HistorianGatewayErrorReasons.AggregateNotSupportedForTag, HistorianGatewayErrorReasons.Domain },
{ StatusCode.FailedPrecondition, null, null },
{ StatusCode.FailedPrecondition, "LIVE_WRITES_DISABLED", HistorianGatewayErrorReasons.Domain },
{ StatusCode.FailedPrecondition, HistorianGatewayErrorReasons.AggregateNotSupportedForTag, "example.com" },
{ StatusCode.FailedPrecondition, HistorianGatewayErrorReasons.AggregateNotSupportedForTag, null },
};
[Theory]
[MemberData(nameof(NotTheRefusal))]
public void Every_other_shape_is_not_the_refusal(StatusCode? code, string? reason, string? domain) =>
GatewayErrorReasons.IsAggregateNotSupportedForTag(Gw(code, reason, domain)).ShouldBeFalse();
[Fact]
public void A_foreign_domains_reason_is_no_reason() =>
GatewayErrorReasons.ReasonOf(Gw(StatusCode.FailedPrecondition, "AGGREGATE_NOT_SUPPORTED_FOR_TAG", "example.com")).ShouldBeNull();
[Fact]
public void A_non_gateway_exception_is_not_the_refusal()
{
GatewayErrorReasons.IsAggregateNotSupportedForTag(new IOException("x")).ShouldBeFalse();
GatewayErrorReasons.ReasonOf(new IOException("x")).ShouldBeNull();
}
}
@@ -70,8 +70,8 @@ public sealed class DiscreteTagTimeWeightedWireTests
/// <summary> /// <summary>
/// lmxopcua#900: the residual #897 case — a node declared numeric whose AVEVA tag is discrete still reaches AVEVA. A /// lmxopcua#900: the residual #897 case — a node declared numeric whose AVEVA tag is discrete still reaches AVEVA. A
/// gateway at or after historiangw#135 answers FailedPrecondition + ErrorInfo <c>AGGREGATE_NOT_SUPPORTED_FOR_TAG</c> /// gateway at or after historiangw#135 answers FailedPrecondition + ErrorInfo <c>AGGREGATE_NOT_SUPPORTED_FOR_TAG</c>
/// (surfaced as Client 1.0.0 surfaces it: a <c>HistorianGatewayException</c> wrapping the <see cref="RpcException"/> and its /// (surfaced as Client 1.1.0 surfaces it: a <c>HistorianGatewayException</c> built from the <see cref="RpcException"/>,
/// trailers), which is <c>BadAggregateNotSupported</c> for the node and no health failure. Control: an older gateway's /// carrying the typed reason), which is <c>BadAggregateNotSupported</c> for the node and no health failure. Control: an older gateway's
/// <c>Internal</c> answer for the same read keeps today's handling (not BadAggregateNotSupported, counted). /// <c>Internal</c> answer for the same read keeps today's handling (not BadAggregateNotSupported, counted).
/// </summary> /// </summary>
[Theory] [Theory]
@@ -140,7 +140,7 @@ public sealed class DiscreteTagTimeWeightedWireTests
/// <summary> /// <summary>
/// true: answer the discrete tag's refused modes as a gateway at or after historiangw#135 does (FailedPrecondition + /// true: answer the discrete tag's refused modes as a gateway at or after historiangw#135 does (FailedPrecondition +
/// ErrorInfo), wrapped the way Client 1.0.0's MapRpcException wraps it; false: the older Internal answer. /// ErrorInfo), built the way Client 1.1.0's MapRpcException builds it; false: the older Internal answer.
/// </summary> /// </summary>
public volatile bool SendsAggregateRefusal; public volatile bool SendsAggregateRefusal;