chore(historian): HistorianGateway Client/Contracts 1.1.0, typed error reason (#900)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -32,9 +32,6 @@
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Core.Api" 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="libplctag" Version="1.5.2" />
|
||||
<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.Audit" Version="0.1.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.Contracts" Version="1.0.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+7
-5
@@ -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
|
||||
|
||||
@@ -1,72 +1,48 @@
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.HistorianGateway.Client;
|
||||
|
||||
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).
|
||||
/// attaches in the standard <c>grpc-status-details-bin</c> trailer, domain <see cref="HistorianGatewayErrorReasons.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.
|
||||
/// Client 1.1.0 parses the trailer itself and exposes it on every <see cref="HistorianGatewayException"/> it throws
|
||||
/// (<see cref="HistorianGatewayException.StatusCode"/>, <see cref="HistorianGatewayException.ErrorReason"/>,
|
||||
/// <see cref="HistorianGatewayException.ErrorDomain"/>), so this reads those typed properties. The 1.0.0-era trailer
|
||||
/// parsing (<c>Grpc.StatusProto</c> over the inner <see cref="RpcException"/>) is gone: the published client maps every
|
||||
/// <see cref="RpcException"/> of every call to a <see cref="HistorianGatewayException"/> (or an
|
||||
/// <see cref="OperationCanceledException"/> for a caller cancellation), so no raw one reaches the data source.
|
||||
/// </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.
|
||||
/// The gateway's <c>ErrorInfo.reason</c> on <paramref name="ex"/> (or an inner exception), or <see langword="null"/>
|
||||
/// when it sent none or sent one from another domain.
|
||||
/// </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;
|
||||
}
|
||||
public static string? ReasonOf(Exception ex) =>
|
||||
GatewayExceptionOf(ex) is { ErrorDomain: HistorianGatewayErrorReasons.Domain } gw ? gw.ErrorReason : 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).
|
||||
/// <see cref="StatusCode.FailedPrecondition"/> AND reason <see cref="HistorianGatewayErrorReasons.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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -25,8 +25,6 @@
|
||||
<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. -->
|
||||
|
||||
+3
-2
@@ -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>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
|
||||
/// <c>HistorianRpcFaults.ToRpcException</c>). 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 <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).
|
||||
/// </summary>
|
||||
public sealed class GatewayAggregateNotSupportedWireTests
|
||||
|
||||
+63
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -70,8 +70,8 @@ public sealed class DiscreteTagTimeWeightedWireTests
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// (surfaced as Client 1.0.0 surfaces it: a <c>HistorianGatewayException</c> wrapping the <see cref="RpcException"/> and its
|
||||
/// trailers), which is <c>BadAggregateNotSupported</c> for the node and no health failure. Control: an older gateway's
|
||||
/// (surfaced as Client 1.1.0 surfaces it: a <c>HistorianGatewayException</c> built from the <see cref="RpcException"/>,
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
@@ -140,7 +140,7 @@ public sealed class DiscreteTagTimeWeightedWireTests
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public volatile bool SendsAggregateRefusal;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user