bc7e58ed64
Every Gateway HistoryRead was preceded by a BrowseTagNames catalog stream keyed by a glob filter (a name with '*' or '?' streamed every match), and the 4096-entry presence cache evicted an arbitrary entry (Keys.First()) when full, so a sweep over more tags thrashed it. The presence check is now one exact unary GetTagMetadata (null = absent). Only the deployed gateway's known misparse of a missing tag (Internal / Unknown / InvalidDataException) falls back to the exact-name catalog check; any other failure propagates and never becomes absence. The positive TTL is 5 minutes (was 60 s); a full cache drops expired entries first, else the one closest to expiry. Wire tests cover the working, the misparsing and the refusing gateway. Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22 in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
177 lines
9.2 KiB
C#
177 lines
9.2 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.HistorianGateway.Client;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
|
|
|
/// <summary>
|
|
/// Concrete <see cref="IHistorianGatewayClient"/> backed by the published
|
|
/// <see cref="HistorianGatewayClient"/> package client. Each seam method forwards directly to the
|
|
/// matching client wrapper — both sides speak the same generated <c>historian_gateway.v1</c> proto
|
|
/// types, so no shape translation happens here. The package client's typed exception hierarchy
|
|
/// (<c>HistorianGatewayUnavailableException</c> et al.) is allowed to surface unchanged; the
|
|
/// <see cref="GatewayHistorianDataSource"/> records it as a health failure and the node manager
|
|
/// turns it into a Bad HistoryRead result.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Lazy channel.</b> <see cref="Create"/> calls <see cref="HistorianGatewayClient.Create"/>,
|
|
/// which constructs a <c>GrpcChannel</c> over a <c>SocketsHttpHandler</c> without opening a
|
|
/// connection — the first RPC dials. Constructing the adapter therefore performs no network I/O,
|
|
/// which the offline seam tests rely on (they build from bogus endpoints and must not connect).
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDisposable
|
|
{
|
|
private readonly HistorianGatewayClient _inner;
|
|
|
|
private HistorianGatewayClientAdapter(HistorianGatewayClient inner) => _inner = inner;
|
|
|
|
/// <summary>
|
|
/// Builds an adapter over a freshly created package client mapped from the bound
|
|
/// <see cref="ServerHistorianOptions"/>. No connection is opened (lazy channel).
|
|
/// </summary>
|
|
/// <param name="options">The bound <c>ServerHistorian</c> configuration (endpoint, key, TLS posture).</param>
|
|
/// <param name="loggerFactory">Logger factory threaded into the package client's channel diagnostics.</param>
|
|
/// <returns>A ready-to-use adapter whose underlying channel has not yet dialed the gateway.</returns>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// <see cref="ServerHistorianOptions.Endpoint"/> is empty or not an absolute <c>http(s)</c> URI.
|
|
/// Defense in depth (archreview 06/S-11): the Host's <c>ServerHistorianOptionsValidator</c> fails
|
|
/// these configs at startup, but any caller that bypasses <c>ValidateOnStart</c> (tests, the live
|
|
/// fixture, future non-Host wiring) still gets a named, config-key-carrying error here instead of
|
|
/// the raw <see cref="UriFormatException"/> the bare <c>new Uri(...)</c> ctor throws.
|
|
/// </exception>
|
|
public static HistorianGatewayClientAdapter Create(ServerHistorianOptions options, ILoggerFactory loggerFactory)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(loggerFactory);
|
|
|
|
// Fail fast with a named, actionable error rather than letting `new Uri("")` throw a raw
|
|
// UriFormatException deep in the factory (archreview 06/S-11). The Endpoint is not a secret,
|
|
// so it is safe to echo; the ApiKey is never surfaced.
|
|
if (!Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var endpointUri)
|
|
|| (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
|
|
}
|
|
|
|
// TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects
|
|
// each of them outright when UseTls=false ("<name> is a TLS-only option and requires
|
|
// UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer-
|
|
// Certificate defaults to false, so RequireCertificateValidation was always sent as true and
|
|
// every http:// deployment crashed at startup, even though the scheme is documented as the
|
|
// supported way to select h2c. There is no certificate to have a posture about here.
|
|
var clientOptions = new HistorianGatewayClientOptions
|
|
{
|
|
Endpoint = endpointUri,
|
|
ApiKey = options.ApiKey,
|
|
UseTls = options.UseTls,
|
|
CaCertificatePath = options.UseTls ? options.CaCertificatePath : null,
|
|
// INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate
|
|
// (opt-in to accept a self-signed cert) is the negation of the client's
|
|
// RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a
|
|
// pinned CaCertificatePath always verifies.
|
|
RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate,
|
|
DefaultCallTimeout = options.CallTimeout,
|
|
LoggerFactory = loggerFactory,
|
|
};
|
|
|
|
return new HistorianGatewayClientAdapter(HistorianGatewayClient.Create(clientOptions));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// One exact unary lookup (GetTagMetadata: null = absent). The deployed gateway's SDK misparses the reply for a MISSING
|
|
// tag and surfaces it as Internal ("GetTagInfo response ended unexpectedly"); only that answer falls back to the tag
|
|
// catalog, which proves presence by exact name (BrowseTagNames takes a glob, so a name holding '*' or '?' would stream
|
|
// every match — the exact compare keeps it correct). Any other failure propagates: it is never turned into absence.
|
|
public async Task<bool> TagExistsAsync(string tag, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
return await _inner.GetTagMetadataAsync(tag, ct).ConfigureAwait(false) is not null;
|
|
}
|
|
catch (Exception ex) when (!ct.IsCancellationRequested && IsMetadataMisparse(ex))
|
|
{
|
|
// Fall through to the catalog.
|
|
}
|
|
|
|
var found = false;
|
|
await foreach (var name in _inner.BrowseTagNamesAsync(tag, ct).ConfigureAwait(false))
|
|
found |= string.Equals(name, tag, StringComparison.OrdinalIgnoreCase);
|
|
return found; // drain first: a trailing stream fault must propagate and must not become a cache entry
|
|
}
|
|
|
|
/// <summary>The known metadata misparse: an Internal/Unknown status, or the parser's own InvalidDataException.</summary>
|
|
private static bool IsMetadataMisparse(Exception ex)
|
|
{
|
|
for (Exception? e = ex; e is not null; e = e.InnerException)
|
|
{
|
|
if (e is System.IO.InvalidDataException) return true;
|
|
if (e is Grpc.Core.RpcException rpc)
|
|
return rpc.StatusCode is Grpc.Core.StatusCode.Internal or Grpc.Core.StatusCode.Unknown;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
|
string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) =>
|
|
_inner.ReadRawAsync(tag, startUtc, endUtc, maxValues, ct);
|
|
|
|
/// <inheritdoc />
|
|
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
|
|
string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken ct) =>
|
|
_inner.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, ct);
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
|
|
string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct) =>
|
|
_inner.ReadAtTimeAsync(tag, timestampsUtc, ct);
|
|
|
|
/// <inheritdoc />
|
|
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct)
|
|
{
|
|
HistorianEventFilter? filter = sourceName is null
|
|
? null
|
|
: new HistorianEventFilter
|
|
{
|
|
PropertyName = "Source_Object",
|
|
Comparison = HistorianEventComparison.Equal,
|
|
Value = sourceName,
|
|
};
|
|
|
|
return _inner.ReadEventsAsync(startUtc, endUtc, filter, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<WriteAck> WriteLiveValuesAsync(
|
|
string tag, IReadOnlyList<HistorianLiveValue> values, CancellationToken ct) =>
|
|
_inner.WriteLiveValuesAsync(tag, values, ct);
|
|
|
|
/// <inheritdoc />
|
|
public Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct) =>
|
|
_inner.SendEventAsync(evt, ct);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TagOperationResults> EnsureTagsAsync(
|
|
IReadOnlyList<HistorianTagDefinition> definitions, CancellationToken ct) =>
|
|
_inner.EnsureTagsAsync(definitions, ct);
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> ProbeAsync(CancellationToken ct) => _inner.ProbeAsync(ct);
|
|
|
|
/// <inheritdoc />
|
|
public Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct) =>
|
|
_inner.GetConnectionStatusAsync(ct);
|
|
|
|
/// <summary>Disposes the underlying package client (and its channel). Prefer <see cref="DisposeAsync"/>.</summary>
|
|
public void Dispose() => _inner.Dispose();
|
|
|
|
/// <summary>Asynchronously disposes the underlying package client (and its channel).</summary>
|
|
/// <returns>A task that completes when the client has been disposed.</returns>
|
|
public ValueTask DisposeAsync() => _inner.DisposeAsync();
|
|
}
|