f9f1b8fcee
Gate record: docs/plans/2026-07-20-localdb-phase2-live-gate.md. Checks 1, 2, 5, 6 pass. Checks 3 and 4 are NOT satisfied — the defect they were meant to confirm turned out to be the opposite of what the plan assumed. Three defects crash-looped every driver node before check 1 could even run: 1. An empty ServerHistorian:ApiKey kills the host. ServerHistorianOptions- Validator exists to turn exactly that class of failure into a named OptionsValidationException, but its documented fail tier explicitly excluded ApiKey on the reasoning that a keyless client "degrades — the gateway rejects calls". It does not: the client validates its own options at construction, so the process dies during Akka startup and never makes a call. 2. UseTls disagreeing with the endpoint scheme kills the host too, in both directions (both messages confirmed in the shipped client assembly). Moving an endpoint from https to http without clearing UseTls is an ordinary migration slip. 3. Plaintext h2c was UNREACHABLE. HistorianGatewayClientAdapter forwarded the TLS-only options unconditionally, and AllowUntrustedServerCertificate defaults to false, so it always sent RequireCertificateValidation=true — which the client rejects outright when UseTls=false. Every http:// deployment crashed, though the scheme is documented as the supported way to select h2c, and the only workaround was to assert a certificate posture for a connection that has no certificate. The fourth was the blocker, and it is Phase 2's own: 4. The drain gate deferred to a Primary that cannot deliver. Redundancy roles are elected CLUSTER-WIDE; the alarm queue is PAIR-LOCAL. On the rig the elected driver Primary is central-1 — it carries the driver Akka role, replicates nobody's LocalDb and does not even run the alarm historian — so every driver node logged "Historian drain suspended", including the two site-b nodes that have no peer at all. Nothing drained anywhere, where before Phase 2 it drained fine. The cost is not a duplicate; it is the buffer growing to the capacity wall and evicting the audit trail it exists to protect. Fixed in three layers: a separate ShouldDrainAlarmHistory policy (unknown role drains; the two gates now deliberately disagree, and a test pins that); peer- host matching in DriverHostActor so a node stands down only for a Primary holding its rows; and AddAlarmHistorian short-circuiting the gate when replication is unconfigured — testing BOTH Replication:PeerAddress and SyncListenPort, since only the dialing half sets the former while both halves share the queue. Every one of these follows from the asymmetry: a false allow costs a duplicate row, which at-least-once delivery already accepts and payload-hash ids collapse; a false deny loses data silently. A third vacuous test, caught by the same delete-the-guard discipline: the role-view tests stayed green with the guard removed, because AwaitAssert polls until an assertion passes and the assertion was "reads open" — which is the SEEDED value, satisfied at the first poll before the actor processed anything. They now assert the sequence of published values through a recording view; the control then goes red for exactly the cases that matter. Migration evidence: 11 legacy rows across two deliberately overlapping files converged to exactly 9 identical rows on both nodes, proving D-6's payload-hash identity on real nodes rather than in a fixture. Open design fork, recorded in the gate doc rather than decided here: a pair cannot currently identify its own Primary, so both halves drain. Safe in every topology — nothing loses data — but the gate's de-duplication benefit is unrealised until roles are scoped per pair. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
143 lines
7.6 KiB
C#
143 lines
7.6 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 />
|
|
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();
|
|
}
|