Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs
T
Joseph Doherty 0b4b2e4cfd refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend)
The HistorianGateway driver is now the sole historian read/write+alarm backend, so the
Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver,
.Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully
retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver
factory): the Host probe registration, the AdminUI driver-config surface (driver page,
tag-config editor/model/validator entry, address picker/builder, driver-type catalog +
dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware
connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from
AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes
the stale XML docs that named Wonderware as the production backend.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-26 19:25:21 -04:00

346 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// <summary>
/// Server-side <see cref="IHistorianDataSource"/> backed by the HistorianGateway gRPC surface
/// (via the <see cref="IHistorianGatewayClient"/> seam). Translates OPC UA HistoryRead requests
/// to gateway read calls and maps the wire shapes back to the driver-agnostic
/// <see cref="DataValueSnapshot"/> / <see cref="HistoricalEvent"/> carriers using the pure
/// mappers in <c>Mapping/</c>.
/// </summary>
/// <remarks>
/// The data source owns no historian connection of its own — it delegates to the gateway, which
/// pools and amortizes the underlying historian sessions. A thrown gateway exception is recorded
/// as a health failure and rethrown: the node manager turns it into a Bad HistoryRead result, so
/// a backend fault never crashes the host. An empty time window is a successful (GoodNoData)
/// read, not a fault. Health counters follow the single-<c>_healthLock</c> discipline ported
/// from <c>WonderwareHistorianClient</c> so <c>TotalSuccesses + TotalFailures == TotalQueries</c>
/// holds at every observed snapshot.
/// </remarks>
public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDisposable
{
/// <summary>
/// <see cref="ConnectionStatus.ConnectionKind"/> is a combinable [Flags] value: the
/// process-data connection is bit 0 (value 1), the event connection is bit 1 (value 2).
/// </summary>
private const uint ProcessConnectionFlag = 1;
private const uint EventConnectionFlag = 2;
private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayHistorianDataSource> _logger;
private readonly object _healthLock = new();
private DateTime? _lastSuccessUtc;
private DateTime? _lastFailureUtc;
private string? _lastError;
private long _totalQueries;
private long _totalSuccesses;
private long _totalFailures;
private int _consecutiveFailures;
private bool _processConnectionOpen;
private bool _eventConnectionOpen;
/// <summary>Creates a gateway-backed historian data source.</summary>
/// <param name="client">The gateway client seam used for all reads.</param>
/// <param name="logger">Diagnostic logger; failures are recorded without leaking tag/host detail.</param>
public GatewayHistorianDataSource(IHistorianGatewayClient client, ILogger<GatewayHistorianDataSource> logger)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(logger);
_client = client;
_logger = logger;
}
/// <inheritdoc />
public async Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
try
{
// The gateway seam caps with an int; OPC UA hands us a uint, so clamp to int range.
var maxValues = (int)Math.Min(maxValuesPerNode, int.MaxValue);
var samples = new List<HistorianSample>();
await foreach (var sample in _client
.ReadRawAsync(fullReference, startUtc, endUtc, maxValues, cancellationToken)
.ConfigureAwait(false))
{
samples.Add(sample);
}
var snapshots = SampleMapper.ToSnapshots(samples);
RecordOutcome(success: true, error: null);
return new HistoryReadResult(snapshots, ContinuationPoint: null);
}
catch (Exception ex)
{
RecordReadFailure(ex);
throw;
}
}
/// <inheritdoc />
public async Task<HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
try
{
// Total/Count are now native gateway retrieval modes — no client-side scaling
// (unlike the Wonderware path that derived Total as Average × interval-seconds).
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
var buckets = new List<HistorianAggregateSample>();
await foreach (var bucket in _client
.ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken)
.ConfigureAwait(false))
{
buckets.Add(bucket);
}
var snapshots = SampleMapper.ToAggregateSnapshots(buckets);
RecordOutcome(success: true, error: null);
return new HistoryReadResult(snapshots, ContinuationPoint: null);
}
catch (Exception ex)
{
RecordReadFailure(ex);
throw;
}
}
/// <inheritdoc />
public async Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
try
{
var samples = await _client
.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken)
.ConfigureAwait(false);
var aligned = AlignAtTimeSnapshots(timestampsUtc, samples);
RecordOutcome(success: true, error: null);
return new HistoryReadResult(aligned, ContinuationPoint: null);
}
catch (Exception ex)
{
RecordReadFailure(ex);
throw;
}
}
/// <inheritdoc />
/// <remarks>
/// Depends on the target gateway running with <c>RuntimeDb:EventReadsEnabled=true</c> (the
/// SQL alarm-history path). The <paramref name="sourceName"/> is passed through to the
/// gateway, but its SQL <c>ReadEvents</c> source filter may not be present yet — so this
/// adapter also filters the mapped events by <see cref="HistoricalEvent.SourceName"/>
/// client-side (defensive; remove once the server filter is confirmed). The
/// <paramref name="maxEvents"/> cap is enforced client-side by early stream termination:
/// a non-positive value applies no client cap (the gateway may still apply its
/// <c>EventReadMaxRows</c>); a positive cap stops at N and sets a non-null
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> iff at least one further matching
/// event existed (the Core.Abstractions-009 truncation signal).
/// </remarks>
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
{
try
{
var hasCap = maxEvents > 0;
var collected = new List<HistoricalEvent>(hasCap ? maxEvents : 0);
var truncated = false;
await foreach (var wireEvent in _client
.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken)
.ConfigureAwait(false))
{
var mapped = EventMapper.ToHistoricalEvent(wireEvent);
// Defensive client-side source filter: the gateway's SQL ReadEvents source filter
// may not be present, so drop any event whose source does not match the request.
if (sourceName is not null && !string.Equals(mapped.SourceName, sourceName, StringComparison.Ordinal))
{
continue;
}
// One more matching event arriving once the cap is full means the result is
// truncated — stop draining and flag it (Core.Abstractions-009).
if (hasCap && collected.Count == maxEvents)
{
truncated = true;
break;
}
collected.Add(mapped);
}
RecordOutcome(success: true, error: null);
// A non-null, opaque token signals truncation to the caller (Core.Abstractions-009).
// The gateway has no resumable cursor, so the token's contents carry no paging state —
// its presence alone is the "more events exist" signal. A fresh array per call keeps it
// from being shared/mutated.
return new HistoricalEventsResult(collected, truncated ? new byte[] { 0x01 } : null);
}
catch (Exception ex)
{
RecordReadFailure(ex);
throw;
}
}
/// <inheritdoc />
public HistorianHealthSnapshot GetHealthSnapshot()
{
lock (_healthLock)
{
return new HistorianHealthSnapshot(
TotalQueries: _totalQueries,
TotalSuccesses: _totalSuccesses,
TotalFailures: _totalFailures,
ConsecutiveFailures: _consecutiveFailures,
LastSuccessTime: _lastSuccessUtc,
LastFailureTime: _lastFailureUtc,
LastError: _lastError,
// Cached connection flags last observed by RefreshConnectionStateAsync. The gateway
// is non-clustered to us, so node fields are null/empty (mirrors the Wonderware
// client's Finding 010 posture).
ProcessConnectionOpen: _processConnectionOpen,
EventConnectionOpen: _eventConnectionOpen,
ActiveProcessNode: null,
ActiveEventNode: null,
Nodes: []);
}
}
/// <summary>
/// Refreshes the cached process / event connection flags by querying the gateway's
/// connection status. Intended to be driven by a periodic health hosted-service, keeping
/// <see cref="GetHealthSnapshot"/> pure observation (it never performs I/O). The flags are
/// derived from <see cref="ConnectionStatus.ConnectedToServer"/> AND the matching
/// <see cref="ConnectionStatus.ConnectionKind"/> flag bit. A failed status query is a health
/// probe — it never throws to the caller; both flags degrade to closed until the next
/// successful refresh.
/// </summary>
/// <param name="cancellationToken">A token to cancel the status query.</param>
/// <returns>A task that completes when the cached flags have been updated.</returns>
public async Task RefreshConnectionStateAsync(CancellationToken cancellationToken)
{
bool processOpen;
bool eventOpen;
try
{
var status = await _client.GetConnectionStatusAsync(cancellationToken).ConfigureAwait(false);
var connected = status.ConnectedToServer;
processOpen = connected && (status.ConnectionKind & ProcessConnectionFlag) != 0;
eventOpen = connected && (status.ConnectionKind & EventConnectionFlag) != 0;
}
catch (Exception)
{
// A health probe must never crash the host; an unreachable gateway degrades both
// connection flags to closed until the next successful refresh.
_logger.LogDebug("Historian gateway connection-status refresh failed; treating both connections as closed.");
processOpen = false;
eventOpen = false;
}
lock (_healthLock)
{
_processConnectionOpen = processOpen;
_eventConnectionOpen = eventOpen;
}
}
/// <summary>
/// Reconciles a gateway at-time reply against the requested timestamps to honour the
/// <see cref="IHistorianDataSource.ReadAtTimeAsync"/> contract: exactly one snapshot per
/// requested timestamp, in request order. Returned samples are indexed by timestamp ticks;
/// any requested timestamp the gateway did not return is filled with a Bad-quality
/// (<c>0x80000000</c>) snapshot stamped at the requested time rather than positionally
/// misaligning values. The alignment logic was ported from the now-retired Wonderware
/// client's at-time snapshot reconciliation.
/// </summary>
private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots(
IReadOnlyList<DateTime> timestampsUtc, IReadOnlyList<HistorianSample> samples)
{
// Index returned samples by timestamp ticks. Duplicate timestamps keep the first.
var byTicks = new Dictionary<long, HistorianSample>(samples.Count);
foreach (var sample in samples)
{
if (sample.Timestamp is null) continue;
byTicks.TryAdd(sample.Timestamp.ToDateTime().Ticks, sample);
}
var result = new DataValueSnapshot[timestampsUtc.Count];
for (var i = 0; i < timestampsUtc.Count; i++)
{
var requested = DateTime.SpecifyKind(timestampsUtc[i], DateTimeKind.Utc);
if (byTicks.TryGetValue(requested.Ticks, out var sample))
{
// Reuse the shared sample mapper for value + quality, then re-stamp the source
// timestamp to the requested time per the ReadAtTime contract.
result[i] = SampleMapper.ToSnapshot(sample) with { SourceTimestampUtc = requested };
}
else
{
// Gap — gateway returned no sample for this timestamp. Per the contract this is a
// Bad-quality snapshot stamped at the requested time, not a dropped row.
result[i] = new DataValueSnapshot(
Value: null,
StatusCode: 0x80000000u, // Bad
SourceTimestampUtc: requested,
ServerTimestampUtc: DateTime.UtcNow);
}
}
return result;
}
/// <summary>
/// Records a failed read: bumps the health counters and logs a generic, redaction-safe
/// debug line (no tag, host, or value). The exception itself is rethrown by the caller.
/// </summary>
private void RecordReadFailure(Exception ex)
{
RecordOutcome(success: false, error: ex.Message);
_logger.LogDebug("Historian gateway read operation failed and was recorded as a health failure.");
}
/// <summary>
/// Records the outcome of a single read — increments <c>_totalQueries</c> and exactly one of
/// <c>_totalSuccesses</c> / <c>_totalFailures</c> under a single <c>_healthLock</c>
/// acquisition so a concurrent <see cref="GetHealthSnapshot"/> never observes a torn state.
/// </summary>
private void RecordOutcome(bool success, string? error)
{
lock (_healthLock)
{
_totalQueries++;
if (success)
{
_totalSuccesses++;
_consecutiveFailures = 0;
_lastSuccessUtc = DateTime.UtcNow;
}
else
{
_totalFailures++;
_consecutiveFailures++;
_lastFailureUtc = DateTime.UtcNow;
_lastError = error;
}
}
}
/// <summary>Disposes the underlying gateway client. Prefer <see cref="DisposeAsync"/>.</summary>
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>Asynchronously disposes the underlying gateway client.</summary>
/// <returns>A task that completes when the client has been disposed.</returns>
public ValueTask DisposeAsync() => _client.DisposeAsync();
}