Files
histsdk/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs
T
Joseph Doherty 3525653c2b fix(grpc): extended-property read parser + GetConnectionStatus over gRPC
- HistorianTagExtendedPropertyProtocol.ParseResponse: fix the multi-property/
  multi-group response shape captured live from the 2023 R2 server. The server
  returns one group per property (the tag name repeats), each propertyCount=1, and
  a uint16 searchability-flags trailer per property (0x0003 built-in, 0x0001 user-
  added) — NOT the single-byte group trailer the old model assumed, which drifted
  one byte per group and threw "expected 0x09 found 0x01" on any buffer with more
  than one property. Now reads the per-property uint16 trailer (tolerates a legacy
  1-byte tail). Fixes read-back on both WCF and gRPC. Adds GetTagExtendedPropertiesRaw
  for future captures.
- HistorianGrpcStatusClient.GetConnectionStatusAsync (plan #5): synthesize connection
  status from a measured gRPC handshake (OpenConnection yielding a storage-session
  GUID => connected), mirroring the WCF synthesize-from-probe approach. Routed in
  Historian2020ProtocolDialect on UseGrpc (the WCF path used the MDAS binding, which
  can't reach the gRPC port).
- HistorianGrpcSqlClient: record the negative plan-#4 result — a HistoryService.
  RegisterTags prime does NOT clear the server-side CSrvDbConnection fault (tried live
  on both 0x402/0x401); the op stays bounded behind ProtocolEvidenceMissingException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
2026-06-22 06:03:38 -04:00

119 lines
6.0 KiB
C#

using Google.Protobuf;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Protocol;
using AVEVA.Historian.Client.Wcf;
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
namespace AVEVA.Historian.Client.Grpc;
/// <summary>
/// Executes SQL commands over the 2023 R2 gRPC transport (HCAL R1.1), mirroring
/// <see cref="HistorianWcfSqlClient"/>'s two-op <c>ExeC</c>/<c>GetR</c> flow. The 2020 WCF path uses a
/// dedicated <c>GetRecordSetByteStream</c> op; the gRPC front door has no such RPC, so the NRBF
/// recordset stream would be fetched through the generic <c>RetrievalService.GetNextQueryResultBuffer</c>
/// keyed by the query handle <c>ExecuteSqlCommand</c> returns. <c>ExecuteSqlCommand</c> takes the
/// uppercase string session handle; the result-buffer fetch takes the transient <c>uint</c> client
/// handle (both come from the one Open2 session).
/// <para>
/// <b>SERVER-WALLED (captured 2026-06-22).</b> The 2023 R2 front-door
/// <c>RetrievalService.ExecuteSqlCommand</c> faults server-side before returning a query handle:
/// the response carries native error 38 wrapping a managed
/// <c>System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand</c>.
/// This is a server-side <c>CSrvDbConnection</c> (SQL DB-connection) precondition that the pure
/// managed gRPC session does not establish — the same class of wall as
/// <c>StorageService.OpenStorageConnection</c>. Priming <c>Retr.GetV</c> does not clear it, and
/// <b>a <c>HistoryService.RegisterTags</c> prime does NOT clear it either</b> (tried live 2026-06-22 on
/// both read-only <c>0x402</c> and write-enabled <c>0x401</c> sessions: <c>RegisterTags</c> itself
/// returned false and <c>ExecuteSqlCommand</c> faulted with the identical native-38 IndexOutOfRange) —
/// so unlike the OpenStorageConnection wall, the SQL DB-connection context is not established by the
/// RegisterTags family. The request framing here is the captured/expected shape; the op stays bounded
/// behind <see cref="ProtocolEvidenceMissingException"/> until the DB-connection registration is
/// reproduced. Use the WCF transport for SQL.
/// </para>
/// </summary>
internal static class HistorianGrpcSqlClient
{
// GetNextQueryResultBuffer is byte-stream-paged; a small record set returns in one page. Runaway guard.
private const int MaxPages = 4096;
public static Task<HistorianSqlResult> ExecuteSqlCommandAsync(
HistorianClientOptions options,
string command,
HistorianSqlExecuteOption option,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command);
return Task.Run(() => ExecuteSqlCommand(options, command, option, cancellationToken), cancellationToken);
}
private static HistorianSqlResult ExecuteSqlCommand(
HistorianClientOptions options,
string command,
HistorianSqlExecuteOption option,
CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
// Prime the Retrieval service version handshake (Retr.GetV) before the string-handle SQL op, as
// the native WCF SQL path does — the server-side ExecuteSqlCommand otherwise faults.
retrievalClient.GetRetrievalInterfaceVersion(
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
GrpcRetrieval.ExecuteSqlCommandResponse exec = retrievalClient.ExecuteSqlCommand(
new GrpcRetrieval.ExecuteSqlCommandRequest
{
StrHandle = session.StringHandle,
StrCommand = command,
UiOption = (uint)option,
UiQueryHandle = 0
},
connection.Metadata,
Deadline(),
cancellationToken);
if (!(exec.Status?.BSuccess ?? false))
{
// Captured 2026-06-22: the server-side CSrvDbConnection.ExecuteSqlCommand throws
// IndexOutOfRange (native error 38) — a DB-connection precondition the pure managed gRPC
// session doesn't establish. Surface the SDK's evidence-missing signal rather than a raw
// server fault. See the class remarks.
throw new ProtocolEvidenceMissingException(
"ExecuteSqlCommand over gRPC: server-side CSrvDbConnection.ExecuteSqlCommand faults " +
"(IndexOutOfRange / native error 38) — an unmet DB-connection precondition (gRPC transport). Use WCF.");
}
int returnValue = exec.IRetValue;
uint queryHandle = exec.UiQueryHandle;
using MemoryStream accumulated = new();
for (int page = 0; page < MaxPages; page++)
{
cancellationToken.ThrowIfCancellationRequested();
GrpcRetrieval.GetNextQueryResultBufferResponse buffer = retrievalClient.GetNextQueryResultBuffer(
new GrpcRetrieval.GetNextQueryResultBufferRequest { UiHandle = session.ClientHandle, UiQueryHandle = queryHandle },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] resultBuffer = buffer.BtQueryResult?.ToByteArray() ?? [];
// GetR is false-even-on-success: the final page returns false with the data still in the
// buffer, so always consume the buffer first, then stop on a false status or an empty page.
if (resultBuffer.Length > 0)
{
accumulated.Write(resultBuffer, 0, resultBuffer.Length);
}
if (!(buffer.Status?.BSuccess ?? false) || resultBuffer.Length == 0)
{
break;
}
}
return HistorianSqlResultProtocol.Parse(accumulated.ToArray(), returnValue);
}
}