From 76e73e7dbf1160e74d2c89036c4235eefdc566dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 13 Nov 2026 07:17:10 -0500 Subject: [PATCH] chore(historian): HistorianGateway Client/Contracts 1.1.0, typed error reason (#900) 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 --- Directory.Packages.props | 7 +- docs/Historian.md | 12 ++-- .../GatewayErrorReasons.cs | 64 ++++++------------- ...WW.OtOpcUa.Driver.Historian.Gateway.csproj | 2 - .../GatewayAggregateNotSupportedWireTests.cs | 5 +- .../GatewayErrorReasonsTests.cs | 63 ++++++++++++++++++ .../DiscreteTagTimeWeightedWireTests.cs | 6 +- 7 files changed, 98 insertions(+), 61 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayErrorReasonsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index eafd04611..4e0ca941f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,9 +32,6 @@ - - @@ -245,7 +242,7 @@ - - + + \ No newline at end of file diff --git a/docs/Historian.md b/docs/Historian.md index dca6b314d..98896009e 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -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 > 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 > 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 @@ -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 > 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** -([historiangw#131](https://gitea.dohertylan.com/dohertj2/historiangw/issues/131)). The adapter maps +The HistorianGateway **Client and Contracts packages are pinned together at 1.1.0** +([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 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 @@ -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 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 -failure. The reason is read in one place, `GatewayErrorReasons` — from the trailer via `Grpc.StatusProto` while the -HistorianGateway Client pin is 1.0.0; with Client 1.1.0 it becomes `HistorianGatewayException.ErrorReason`. +failure. The reason is read in one place, `GatewayErrorReasons`, from Client 1.1.0's typed +`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 every configuration, including with no historian. Part 5 defines it as every aggregate the *server* supports, and the server diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayErrorReasons.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayErrorReasons.cs index 2f268fbda..d07975f00 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayErrorReasons.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayErrorReasons.cs @@ -1,72 +1,48 @@ using Grpc.Core; +using ZB.MOM.WW.HistorianGateway.Client; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; /// /// The HistorianGateway's machine-readable failure reasons (historiangw#135): a google.rpc.ErrorInfo the gateway -/// attaches in the standard grpc-status-details-bin trailer, domain . The ONE place OtOpcUa -/// reads them (lmxopcua#900). +/// attaches in the standard grpc-status-details-bin trailer, domain . +/// The ONE place OtOpcUa reads them (lmxopcua#900). /// /// -/// The Client pin is 1.0.0, which does not surface the reason, but keeps the gateway's — -/// trailers included — as the of the HistorianGatewayException it throws, so -/// parses the trailer with Grpc.StatusProto. When the pin moves to Client 1.1.0 (not yet on the -/// feed), becomes the one line -/// (ex as ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException)?.ErrorReason (and the constants can point -/// at HistorianGatewayErrorReasons); the Grpc.StatusProto reference can then go. +/// Client 1.1.0 parses the trailer itself and exposes it on every it throws +/// (, , +/// ), so this reads those typed properties. The 1.0.0-era trailer +/// parsing (Grpc.StatusProto over the inner ) is gone: the published client maps every +/// of every call to a (or an +/// for a caller cancellation), so no raw one reaches the data source. /// internal static class GatewayErrorReasons { - /// The ErrorInfo.domain of every reason the gateway sends (its proto package). - public const string Domain = "historian_gateway.v1"; - /// - /// AVEVA refused the aggregate retrieval mode for this tag (TimeWeightedAverage / Integral / … on a discrete or string - /// tag; native CustomError 85 at StartQuery). Sent with . - /// - public const string AggregateNotSupportedForTag = "AGGREGATE_NOT_SUPPORTED_FOR_TAG"; - - /// - /// The gateway's ErrorInfo.reason on (or an inner exception — the client package wraps the - /// ), or when it sent none, sent one from another domain, or sent an - /// unparseable trailer. + /// The gateway's ErrorInfo.reason on (or an inner exception), or + /// when it sent none or sent one from another domain. /// /// A gateway call's exception. /// The reason string, or . - 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(); - 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; - } + public static string? ReasonOf(Exception ex) => + GatewayExceptionOf(ex) is { ErrorDomain: HistorianGatewayErrorReasons.Domain } gw ? gw.ErrorReason : null; /// /// True when is the gateway's refusal of an aggregate for the tag: status - /// AND reason . 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 Internal with no reason (unchanged handling). + /// AND reason . + /// 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 Internal with no reason (unchanged handling). /// /// A gateway call's exception. /// Whether it is the aggregate refusal. public static bool IsAggregateNotSupportedForTag(Exception ex) => - StatusOf(ex) == StatusCode.FailedPrecondition - && string.Equals(ReasonOf(ex), AggregateNotSupportedForTag, StringComparison.Ordinal); + GatewayExceptionOf(ex)?.StatusCode == StatusCode.FailedPrecondition + && 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) - if (e is RpcException rpc) return rpc.StatusCode; + if (e is HistorianGatewayException gw) return gw; return null; } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj index e8b575bec..c1a1d9410 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj @@ -25,8 +25,6 @@ - - diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAggregateNotSupportedWireTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAggregateNotSupportedWireTests.cs index 8e897c76d..aa502dd27 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAggregateNotSupportedWireTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAggregateNotSupportedWireTests.cs @@ -22,8 +22,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; /// FailedPrecondition, detail HistorianAggregateNotSupportedException, and a google.rpc.Status holding a /// google.rpc.ErrorInfo (reason AGGREGATE_NOT_SUPPORTED_FOR_TAG, domain historian_gateway.v1, metadata /// retrieval_mode + native_error_code) in grpc-status-details-bin (shape copied from the gateway's -/// HistorianRpcFaults.ToRpcException). 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 +/// HistorianRpcFaults.ToRpcException). Read through the PUBLISHED Client 1.1.0 (via the production adapter), so this +/// also pins that 1.1.0 surfaces the trailer as the typed HistorianGatewayException.ErrorReason / +/// ErrorDomain / StatusCode that GatewayErrorReasons reads. Controls: every other shape keeps today's handling /// (rethrown, counted as a backend failure). /// public sealed class GatewayAggregateNotSupportedWireTests diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayErrorReasonsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayErrorReasonsTests.cs new file mode 100644 index 000000000..05e9871ba --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayErrorReasonsTests.cs @@ -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; + +/// +/// lmxopcua#900 on Client 1.1.0: classifies by the client's typed +/// / / +/// . These exceptions carry the reason ONLY on the typed properties +/// (no inner , no trailer), so they pin that the typed accessor is what is read. The on-the-wire +/// path through the published client is . +/// +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 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(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DiscreteTagTimeWeightedWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DiscreteTagTimeWeightedWireTests.cs index 0a68347cc..2b0e6377d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DiscreteTagTimeWeightedWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DiscreteTagTimeWeightedWireTests.cs @@ -70,8 +70,8 @@ public sealed class DiscreteTagTimeWeightedWireTests /// /// 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 AGGREGATE_NOT_SUPPORTED_FOR_TAG - /// (surfaced as Client 1.0.0 surfaces it: a HistorianGatewayException wrapping the and its - /// trailers), which is BadAggregateNotSupported for the node and no health failure. Control: an older gateway's + /// (surfaced as Client 1.1.0 surfaces it: a HistorianGatewayException built from the , + /// carrying the typed reason), which is BadAggregateNotSupported for the node and no health failure. Control: an older gateway's /// Internal answer for the same read keeps today's handling (not BadAggregateNotSupported, counted). /// [Theory] @@ -140,7 +140,7 @@ public sealed class DiscreteTagTimeWeightedWireTests /// /// 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. /// public volatile bool SendsAggregateRefusal;