using System.Runtime.CompilerServices; using Google.Protobuf; using Grpc.Core; using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; namespace AVEVA.Historian.Client.Grpc; /// /// 2023 R2 gRPC read orchestrator. Mirrors over the /// gRPC transport: the same native binary buffers travel inside protobuf bytes fields, /// and the same serializers/parsers (, /// ) are reused unchanged. /// /// Operation mapping (2020 WCF → 2023 R2 gRPC): /// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion /// Hist.ValidateClientCredential (loop) → StorageService.ValidateClientCredential (loop) /// Hist.OpenConnection2 → HistoryService.OpenConnection /// Retr.StartQuery2 → RetrievalService.StartQuery /// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop) /// Retr.EndQuery2 → RetrievalService.EndQuery /// /// LIVE-VERIFIED 2026-06-21 against a real 2023 R2 server (interface versions: History=12, /// Retrieval=4, Storage=4). The SSPI/Negotiate token loop maps to /// StorageService.ValidateClientCredential(Handle, InBuff)→(status, OutBuff) — the op that /// kept the 2020 inBuff/outBuff token framing. The gRPC HistoryService dropped /// ValidateClientCredential and gained ExchangeKey, but ExchangeKey is a SEPARATE /// key-exchange/cert-path op, NOT the Negotiate loop: feeding it an NTLM token is rejected at /// round 0 regardless of credentials. An earlier revision wrongly routed the loop to ExchangeKey; /// routing it to StorageService.ValidateClientCredential completes the full read chain. The byte /// payloads (OpenConnection3 v6, token framing, DataQueryRequest, row buffers) are the proven 2020 /// protocol and transfer unchanged — only the History interface integer differs (12 vs the 2020 /// value 11), and that version is buffer-compatible (a live read returns rows). /// internal sealed class HistorianGrpcReadOrchestrator { private const ushort StartQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData; private readonly HistorianClientOptions _options; public HistorianGrpcReadOrchestrator(HistorianClientOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); } public async IAsyncEnumerable ReadRawAsync( string tag, DateTime startUtc, DateTime endUtc, int maxValues, [EnumeratorCancellation] CancellationToken cancellationToken) { ValidateAuth(); cancellationToken.ThrowIfCancellationRequested(); IReadOnlyList rows = await Task.Run( () => RunRawChain(tag, startUtc, endUtc, maxValues, cancellationToken), cancellationToken).ConfigureAwait(false); foreach (HistorianSample sample in rows) { cancellationToken.ThrowIfCancellationRequested(); yield return sample; } } public async IAsyncEnumerable ReadAggregateAsync( string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, [EnumeratorCancellation] CancellationToken cancellationToken) { ValidateAuth(); cancellationToken.ThrowIfCancellationRequested(); IReadOnlyList rows = await Task.Run( () => RunAggregateChain(tag, startUtc, endUtc, mode, interval, cancellationToken), cancellationToken).ConfigureAwait(false); foreach (HistorianAggregateSample sample in rows) { cancellationToken.ThrowIfCancellationRequested(); yield return sample; } } public Task> ReadAtTimeAsync( string tag, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) { ValidateAuth(); cancellationToken.ThrowIfCancellationRequested(); return Task.Run>(() => RunAtTimeChain(tag, timestampsUtc, cancellationToken), cancellationToken); } private void ValidateAuth() { if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName)) { throw new ProtocolEvidenceMissingException( "Managed gRPC read flow currently requires IntegratedSecurity or an explicit UserName + Password."); } } // Spike/Phase-1 seam (pending.md A1): run a raw query against an EXTERNALLY-supplied, already- // authenticated connection + client handle — i.e. NO Create()/handshake here. RunRawChain delegates // to this so the per-call path and the reuse path share one query implementation (DRY). The handshake // reuse-probe test drives this directly to measure whether the server honors a reused session. internal List RunRawQueryOnSession( HistorianGrpcConnection connection, uint clientHandle, string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken) { HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(tag, startUtc, endUtc, maxValues); return RunQuery(connection, clientHandle, request, maxValues, cancellationToken); } private List RunRawChain(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken) { using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken); return RunRawQueryOnSession(connection, clientHandle, tag, startUtc, endUtc, maxValues, cancellationToken); } // Spike/Phase-1 seam (pending.md A1): run an aggregate query against an EXTERNALLY-supplied, // already-authenticated connection + client handle — i.e. NO Create()/handshake here. // RunAggregateChain delegates to this so the per-call path and the reuse path share one query // implementation (DRY). internal List RunAggregateQueryOnSession( HistorianGrpcConnection connection, uint clientHandle, string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken ct) { return RunAggregateQuery(connection, clientHandle, tag, startUtc, endUtc, mode, interval, ct); } private List RunAggregateChain( string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken) { using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken); return RunAggregateQueryOnSession(connection, clientHandle, tag, startUtc, endUtc, mode, interval, cancellationToken); } // Spike/Phase-1 seam (pending.md A1): run an at-time query against an EXTERNALLY-supplied, // already-authenticated connection + client handle — i.e. NO Create()/handshake here. // RunAtTimeChain delegates to this so the per-call path and the reuse path share one // implementation (DRY). internal List RunAtTimeOnSession( HistorianGrpcConnection connection, uint clientHandle, string tag, IReadOnlyList timestampsUtc, CancellationToken ct) { if (timestampsUtc.Count == 0) { return []; } List results = new(timestampsUtc.Count); foreach (DateTime ts in timestampsUtc) { ct.ThrowIfCancellationRequested(); DateTime tsUtc = ts.ToUniversalTime(); List aggregates = RunAggregateQuery( connection, clientHandle, tag, tsUtc - TimeSpan.FromTicks(1), tsUtc + TimeSpan.FromTicks(1), RetrievalMode.Interpolated, TimeSpan.FromTicks(2), ct); if (aggregates.Count == 0) { continue; } HistorianAggregateSample chosen = aggregates[0]; results.Add(new HistorianSample( TagName: chosen.TagName, TimestampUtc: tsUtc, NumericValue: chosen.Value, StringValue: null, Quality: chosen.Quality, QualityDetail: chosen.QualityDetail, OpcQuality: chosen.OpcQuality, PercentGood: 100)); } return results; } private List RunAtTimeChain(string tag, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) { if (timestampsUtc.Count == 0) { return []; } using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken); return RunAtTimeOnSession(connection, clientHandle, tag, timestampsUtc, cancellationToken); } private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken) => HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken); private List RunQuery( HistorianGrpcConnection connection, uint clientHandle, HistorianDataQueryRequest request, int maxValues, CancellationToken cancellationToken) { var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion( new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken); HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options); byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request); uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, "raw", cancellationToken); try { List samples = []; while (true) { cancellationToken.ThrowIfCancellationRequested(); (byte[] resultBuffer, byte[] errorBuffer) = GetNextResultBuffer(retrievalClient, clientHandle, queryHandle, "raw", cancellationToken); if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(resultBuffer, errorBuffer, out IReadOnlyList rows, out bool hasMoreData)) { throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer returned an unparsable result buffer (length={resultBuffer.Length})."); } foreach (HistorianSample sample in rows) { samples.Add(sample); if (samples.Count >= maxValues) { return samples; } } if (!hasMoreData) { return samples; } } } finally { EndQuerySafely(retrievalClient, clientHandle, queryHandle); } } private List RunAggregateQuery( HistorianGrpcConnection connection, uint clientHandle, string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken) { var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion( new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken); HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options); HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval); byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request); uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, $"aggregate {mode}", cancellationToken); try { List samples = []; while (true) { cancellationToken.ThrowIfCancellationRequested(); (byte[] resultBuffer, byte[] errorBuffer) = GetNextResultBuffer(retrievalClient, clientHandle, queryHandle, $"aggregate {mode}", cancellationToken); if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferAggregateRows( resultBuffer, errorBuffer, mode, interval, out IReadOnlyList rows, out bool hasMoreData)) { throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer (aggregate {mode}) returned an unparsable buffer (length={resultBuffer.Length})."); } samples.AddRange(rows); if (!hasMoreData) { return samples; } } } finally { EndQuerySafely(retrievalClient, clientHandle, queryHandle); } } private uint StartQuery( GrpcRetrieval.RetrievalService.RetrievalServiceClient client, uint clientHandle, byte[] requestBuffer, string label, CancellationToken cancellationToken) { GrpcRetrieval.StartQueryResponse response = client.StartQuery( new GrpcRetrieval.StartQueryRequest { UiHandle = clientHandle, UiQueryRequestType = StartQueryRequestType, BtRequestBuffer = ByteString.CopyFrom(requestBuffer) }, null, Deadline(), cancellationToken); if (!(response.Status?.BSuccess ?? false)) { byte[] err = response.Status?.BtError?.ToByteArray() ?? []; throw new InvalidOperationException($"gRPC StartQuery ({label}) failed (errorLen={err.Length})."); } return response.UiQueryHandle; } private (byte[] ResultBuffer, byte[] ErrorBuffer) GetNextResultBuffer( GrpcRetrieval.RetrievalService.RetrievalServiceClient client, uint clientHandle, uint queryHandle, string label, CancellationToken cancellationToken) { GrpcRetrieval.GetNextQueryResultBufferResponse response = client.GetNextQueryResultBuffer( new GrpcRetrieval.GetNextQueryResultBufferRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle }, null, Deadline(), cancellationToken); byte[] errorBuffer = response.Status?.BtError?.ToByteArray() ?? []; if (!(response.Status?.BSuccess ?? false)) { throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer ({label}) failed (errorLen={errorBuffer.Length})."); } byte[] resultBuffer = response.BtQueryResult?.ToByteArray() ?? []; return (resultBuffer, errorBuffer); } private void EndQuerySafely(GrpcRetrieval.RetrievalService.RetrievalServiceClient client, uint clientHandle, uint queryHandle) { try { client.EndQuery( new GrpcRetrieval.EndQueryRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle }, null, Deadline(), CancellationToken.None); } catch { // Best-effort cleanup; the read result is already collected. } } private DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout); }