From ef68016c7a9c7dc5cd267d67990debb2cf778f7d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 01:26:33 -0400 Subject: [PATCH 1/4] feat(grpc): tool the WCF-only config ops over the gRPC transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the config operations that previously only worked over WCF onto RemoteGrpc, reusing the proven 2020 byte serializers verbatim inside the protobuf bytes fields (keyed by the Open2 session handle). Live-verified against a real 2023 R2 server where noted. Read ops (live-verified): - GetRuntimeParameterAsync -> StatusService.GetRuntimeParameter (GETRP serializer) - GetTagExtendedPropertiesAsync -> RetrievalService.GetTagExtendedPropertiesFromName (GetTepByNm serializer + sequence paging; page-0 FillBufferFromVector is the benign no-data terminator, matched to the WCF break-and-return-empty semantics) Server-walled (bounded with captured evidence): - ExecuteSqlCommandAsync -> RetrievalService.ExecuteSqlCommand. The request rides the RPC but the server-side CSrvDbConnection.ExecuteSqlCommand faults (IndexOutOfRange / native err 38) on a DB-connection precondition the pure managed gRPC session doesn't establish (same class as OpenStorageConnection). Surfaced as ProtocolEvidenceMissingException. Write ops (tooled + routed, sandbox-gated — not run destructively live): - EnsureTagAsync / DeleteTagAsync / RenameTagsAsync / AddTagExtendedPropertiesAsync via HistoryService.EnsureTags / DeleteTags / StartJob / AddTagExtendedProperties on a write-enabled (0x401) session, reusing the WCF golden serializers. The WCF priming discovery-dance is omitted (the M3 gRPC write probe worked without it); add it first if a live sandbox run is rejected. Routed in Historian2020ProtocolDialect / HistorianClient on the RemoteGrpc branch. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../Grpc/HistorianGrpcSqlClient.cs | 114 ++++++++++ .../Grpc/HistorianGrpcStatusClient.cs | 44 ++++ .../Grpc/HistorianGrpcTagClient.cs | 72 +++++++ .../Grpc/HistorianGrpcTagWriteOrchestrator.cs | 197 ++++++++++++++++++ src/AVEVA.Historian.Client/HistorianClient.cs | 16 +- .../Protocol/Historian2020ProtocolDialect.cs | 12 +- 6 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs create mode 100644 src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs new file mode 100644 index 0000000..b5bb36d --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs @@ -0,0 +1,114 @@ +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; + +/// +/// Executes SQL commands over the 2023 R2 gRPC transport (HCAL R1.1), mirroring +/// 's two-op ExeC/GetR flow. The 2020 WCF path uses a +/// dedicated GetRecordSetByteStream op; the gRPC front door has no such RPC, so the NRBF +/// recordset stream would be fetched through the generic RetrievalService.GetNextQueryResultBuffer +/// keyed by the query handle ExecuteSqlCommand returns. ExecuteSqlCommand takes the +/// uppercase string session handle; the result-buffer fetch takes the transient uint client +/// handle (both come from the one Open2 session). +/// +/// SERVER-WALLED (captured 2026-06-22). The 2023 R2 front-door +/// RetrievalService.ExecuteSqlCommand faults server-side before returning a query handle: +/// the response carries native error 38 wrapping a managed +/// System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand. +/// This is a server-side CSrvDbConnection (SQL DB-connection) precondition that the pure +/// managed gRPC session does not establish — the same class of wall as +/// StorageService.OpenStorageConnection (whose real precondition is the front-door +/// HistoryService.RegisterTags family). Priming Retr.GetV does not clear it. The request +/// framing here is the captured/expected shape; the op stays bounded behind +/// until the DB-connection registration is reproduced. +/// +/// +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 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); + } +} diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs index f96141a..3e67538 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs @@ -1,5 +1,7 @@ +using Google.Protobuf; using Grpc.Core; using AVEVA.Historian.Client.Models; +using AVEVA.Historian.Client.Wcf; using GrpcStatus = ArchestrA.Grpc.Contract.Status; namespace AVEVA.Historian.Client.Grpc; @@ -142,4 +144,46 @@ internal static class HistorianGrpcStatusClient string? value = response.StrSystemTimeZoneName; return string.IsNullOrEmpty(value) ? null : value; } + + /// + /// Reads a Historian runtime parameter over gRPC (StatusService.GetRuntimeParameter). + /// The request/response byte buffers are the proven 2020 GETRP wire format + /// () carried unchanged inside the protobuf + /// btRequest/btResponse fields; the op keys on the uppercase string session handle. + /// + public static Task GetRuntimeParameterAsync( + HistorianClientOptions options, + string parameterName, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); + return Task.Run(() => GetRuntimeParameter(options, parameterName, cancellationToken), cancellationToken); + } + + private static string? GetRuntimeParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken); + + byte[] request = HistorianRuntimeParameterProtocol.SerializeRequest(parameterName); + + var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel); + GrpcStatus.GetRuntimeParameterResponse response = statusClient.GetRuntimeParameter( + new GrpcStatus.GetRuntimeParameterRequest + { + StrHandle = session.StringHandle, + BtRequest = ByteString.CopyFrom(request) + }, + connection.Metadata, + DateTime.UtcNow.Add(options.RequestTimeout), + cancellationToken); + + if (!(response.Status?.BSuccess ?? false)) + { + return null; + } + + byte[] responseBuffer = response.BtResponse?.ToByteArray() ?? []; + return HistorianRuntimeParameterProtocol.ParseSingleStringResult(responseBuffer); + } } diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs index 7600d80..e8476b0 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs @@ -92,6 +92,78 @@ internal static class HistorianGrpcTagClient return response.BtTagInfos?.ToByteArray() ?? []; } + // GetTagExtendedPropertiesFromName is sequence-paged; a single tag returns everything on page 0 + // and an empty/false buffer next. The cap is a runaway guard (mirrors the WCF path). + private const int MaxExtendedPropertyPages = 64; + + /// + /// Reads a tag's extended (user-defined) properties over gRPC + /// (RetrievalService.GetTagExtendedPropertiesFromName, a string-handle op). The request + /// btTagNames and response btTeps buffers are the proven 2020 GetTepByNm wire + /// format () carried unchanged; paging follows + /// the same sequence loop as the WCF path. + /// + public static Task> GetTagExtendedPropertiesAsync( + HistorianClientOptions options, + string tag, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tag); + return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken); + } + + private static IReadOnlyList GetTagExtendedProperties( + HistorianClientOptions options, string tag, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken); + var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); + + byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag); + List properties = []; + uint sequence = 0; + + for (int page = 0; page < MaxExtendedPropertyPages; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse response = retrievalClient.GetTagExtendedPropertiesFromName( + new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest + { + StrHandle = session.StringHandle, + BtTagNames = ByteString.CopyFrom(tagNames), + UiSequence = sequence + }, + connection.Metadata, + DateTime.UtcNow.Add(options.RequestTimeout), + cancellationToken); + + if (!(response.Status?.BSuccess ?? false)) + { + // A non-success terminates paging. The server signals "no more rows" with a + // CClientUtil::FillBufferFromVector marker (live-confirmed) — including on page 0 when + // the tag has no user-defined properties, which is a legitimate empty result, not an + // error. This mirrors the WCF path, which also breaks (returns empty) rather than throws. + break; + } + + IReadOnlyList rows = + HistorianTagExtendedPropertyProtocol.ParseResponse(response.BtTeps?.ToByteArray() ?? []); + if (rows.Count == 0) + { + break; + } + + foreach (HistorianTagExtendedPropertyRow row in rows) + { + properties.Add(new HistorianTagExtendedProperty(row.PropertyName, row.Value)); + } + + sequence = response.UiSequence; + } + + return properties; + } + // QueryTag (browse paging) request framing, recovered from the .rdata packet-descriptor table // in aahClientManaged.dll (entries {0x6751,1}=StartTagQuery, {0x6752,1}=QueryTag) and confirmed // live: btRequest = u16 marker(0x6752) + u16 version(1) + u16 queryType + u32 startIndex + u32 count. diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs new file mode 100644 index 0000000..480b991 --- /dev/null +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs @@ -0,0 +1,197 @@ +using Google.Protobuf; +using AVEVA.Historian.Client.Models; +using AVEVA.Historian.Client.Wcf; +using GrpcHistory = ArchestrA.Grpc.Contract.History; + +namespace AVEVA.Historian.Client.Grpc; + +/// +/// Tag-configuration write ops over the 2023 R2 gRPC transport, mirroring +/// . Each op opens a write-enabled Open2 session +/// (0x401) and reuses the proven 2020 byte serializers verbatim inside the protobuf +/// bytes fields: +/// +/// HistoryService.EnsureTags (string handle, +/// btTagInfos = ) +/// HistoryService.DeleteTags (uint handle, +/// btTagnames = ) +/// HistoryService.StartJob (string handle, +/// btInput = ) +/// HistoryService.AddTagExtendedProperties +/// (string handle, btTeps = ) +/// +/// +/// Tooled but not yet live-verified. The request framing reuses the WCF serializers proven on +/// the 2020 transport, and the read-side config ops confirm WCF config buffers ride the gRPC RPC +/// unchanged — but these mutate server state (create/delete/rename tags, write properties), so they +/// are gated behind a sandbox-tag in the integration tests and have not been run destructively against +/// a shared live server. The WCF path additionally runs a priming "discovery dance" (UpdC3 + system +/// parameters + cross-service GetV) before the write; the gRPC front door established the equivalent +/// session state in the M3 non-streamed-write probe without it, so it is omitted here pending live +/// confirmation. If a live run is rejected, that priming is the first thing to add. +/// +/// +internal sealed class HistorianGrpcTagWriteOrchestrator +{ + private const uint WriteEnabledConnectionMode = HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode; + + private readonly HistorianClientOptions _options; + + public HistorianGrpcTagWriteOrchestrator(HistorianClientOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public Task EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(definition); + ArgumentException.ThrowIfNullOrWhiteSpace(definition.TagName, nameof(definition)); + // Surface unsupported (non-analog) types early, exactly as the WCF path does. + _ = HistorianTagWriteProtocol.GetAnalogDataTypeCode(definition.DataType); + return Task.Run(() => EnsureTag(definition, cancellationToken), cancellationToken); + } + + private bool EnsureTag(HistorianTagDefinition definition, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode); + + byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata( + tagName: definition.TagName, + description: definition.Description, + engineeringUnit: definition.EngineeringUnit, + dateCreatedUtc: DateTime.UtcNow, + dataType: definition.DataType, + minEU: definition.MinEU, + maxEU: definition.MaxEU, + minRaw: definition.MinRaw, + maxRaw: definition.MaxRaw, + storageRateMs: definition.StorageRateMs, + applyScaling: definition.ApplyScaling, + storageType: definition.StorageType, + integralDivisor: definition.IntegralDivisor); + + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + GrpcHistory.EnsureTagsResponse response = historyClient.EnsureTags( + new GrpcHistory.EnsureTagsRequest + { + StrHandle = session.StringHandle, + BtTagInfos = ByteString.CopyFrom(payload), + ElementCount = 1 + }, + connection.Metadata, + DateTime.UtcNow.Add(_options.RequestTimeout), + cancellationToken); + + return response.Status?.BSuccess ?? false; + } + + public Task DeleteTagAsync(string tagName, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tagName); + return Task.Run(() => DeleteTag(tagName, cancellationToken), cancellationToken); + } + + private bool DeleteTag(string tagName, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode); + + // DeleteTags takes the transient uint client handle (not the string handle), per the WCF wire capture. + byte[] tagNames = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]); + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + GrpcHistory.DeleteTagsResponse response = historyClient.DeleteTags( + new GrpcHistory.DeleteTagsRequest + { + UiHandle = session.ClientHandle, + BtTagnames = ByteString.CopyFrom(tagNames) + }, + connection.Metadata, + DateTime.UtcNow.Add(_options.RequestTimeout), + cancellationToken); + + return response.Status?.BSuccess ?? false; + } + + public Task AddTagExtendedPropertiesAsync( + string tagName, IReadOnlyList properties, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tagName); + ArgumentNullException.ThrowIfNull(properties); + if (properties.Count == 0) + { + throw new ArgumentException("At least one extended property is required.", nameof(properties)); + } + return Task.Run(() => AddTagExtendedProperties(tagName, properties, cancellationToken), cancellationToken); + } + + private bool AddTagExtendedProperties( + string tagName, IReadOnlyList properties, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode); + + byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeAddRequest(tagName, properties); + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + GrpcHistory.AddTagExtendedPropertiesResponse response = historyClient.AddTagExtendedProperties( + new GrpcHistory.AddTagExtendedPropertiesRequest + { + StrHandle = session.StringHandle, + BtTeps = ByteString.CopyFrom(inBuff) + }, + connection.Metadata, + DateTime.UtcNow.Add(_options.RequestTimeout), + cancellationToken); + + return response.Status?.BSuccess ?? false; + } + + public Task RenameTagsAsync( + IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(pairs); + if (pairs.Count == 0) + { + throw new ArgumentException("At least one (old,new) name pair is required.", nameof(pairs)); + } + foreach ((string oldName, string newName) in pairs) + { + ArgumentException.ThrowIfNullOrWhiteSpace(oldName, nameof(pairs)); + ArgumentException.ThrowIfNullOrWhiteSpace(newName, nameof(pairs)); + } + return Task.Run(() => RenameTags(pairs, cancellationToken), cancellationToken); + } + + private HistorianTagRenameResult RenameTags(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode); + + byte[] jobBuffer = HistorianTagRenameProtocol.SerializeRenameJob(pairs); + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + GrpcHistory.StartJobResponse response = historyClient.StartJob( + new GrpcHistory.StartJobRequest + { + StrHandle = session.StringHandle, + BtInput = ByteString.CopyFrom(jobBuffer) + }, + connection.Metadata, + DateTime.UtcNow.Add(_options.RequestTimeout), + cancellationToken); + + bool ok = response.Status?.BSuccess ?? false; + Guid parsedJobId = Guid.Empty; + if (!string.IsNullOrWhiteSpace(response.StrJobid)) + { + Guid.TryParse(response.StrJobid.Trim().Trim('$', '{', '}'), out parsedJobId); + } + + return new HistorianTagRenameResult + { + Accepted = ok, + JobId = parsedJobId, + PairCount = pairs.Count, + Error = ok ? null : "Server rejected the rename job (StartJob returned false). Check that the 'AllowRenameTags' system parameter is enabled.", + }; + } +} diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index d044514..fd7f8d7 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -237,7 +237,9 @@ public sealed class HistorianClient : IAsyncDisposable { ArgumentException.ThrowIfNullOrWhiteSpace(tag); ArgumentNullException.ThrowIfNull(properties); - return new HistorianWcfTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken); + return _options.Transport == HistorianTransport.RemoteGrpc + ? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken) + : new HistorianWcfTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken); } /// Convenience overload of for a single @@ -285,7 +287,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(definition); - return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken); + return _options.Transport == HistorianTransport.RemoteGrpc + ? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken) + : new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken); } /// @@ -299,7 +303,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task DeleteTagAsync(string tagName, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tagName); - return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken); + return _options.Transport == HistorianTransport.RemoteGrpc + ? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken) + : new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken); } /// @@ -325,7 +331,9 @@ public sealed class HistorianClient : IAsyncDisposable public Task RenameTagsAsync(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(pairs); - return new HistorianWcfTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken); + return _options.Transport == HistorianTransport.RemoteGrpc + ? new Grpc.HistorianGrpcTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken) + : new HistorianWcfTagWriteOrchestrator(_options).RenameTagsAsync(pairs, cancellationToken); } public ValueTask DisposeAsync() diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index 82b4290..b13d90b 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -95,21 +95,27 @@ internal sealed class Historian2020ProtocolDialect { cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(name); - return Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken); + return UseGrpc + ? HistorianGrpcStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken) + : Wcf.HistorianWcfStatusClient.GetRuntimeParameterAsync(_options, name, cancellationToken); } public Task> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(tag); - return Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken); + return UseGrpc + ? Grpc.HistorianGrpcTagClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken) + : Wcf.HistorianWcfTagExtendedPropertyClient.GetTagExtendedPropertiesAsync(_options, tag, cancellationToken); } public Task ExecuteSqlCommandAsync(string command, HistorianSqlExecuteOption option, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(command); - return Wcf.HistorianWcfSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken); + return UseGrpc + ? Grpc.HistorianGrpcSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken) + : Wcf.HistorianWcfSqlClient.ExecuteSqlCommandAsync(_options, command, option, cancellationToken); } private static async IAsyncEnumerable Missing( From 0780cec9a7f2a78e053c519646e606f4e08a8803 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 01:26:33 -0400 Subject: [PATCH 2/4] test(grpc): live + gated coverage for the gRPC config ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GetRuntimeParameterAsync_OverGrpc_ReturnsValue (live) - GetTagExtendedPropertiesAsync_OverGrpc_DoesNotThrow (live; empty for system tags) - ExecuteSqlCommandAsync_OverGrpc_IsServerWalled (live; pins the captured wall) - TagWriteLifecycle_OverGrpc_CreatesAddsPropRenamesDeletes — DESTRUCTIVE, gated on HISTORIAN_GRPC_WRITE_SANDBOX_TAG; self-cleaning create->addprop->verify->rename->delete Full gRPC live suite 19/19 green against a real 2023 R2 server (write lifecycle skips without the sandbox tag); 317 offline green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../HistorianGrpcIntegrationTests.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 7cfd9cd..7cf7e47 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -351,6 +351,104 @@ public sealed class HistorianGrpcIntegrationTests Assert.All(samples, s => Assert.Equal(testTag, s.TagName)); } + [Fact] + public async Task GetRuntimeParameterAsync_OverGrpc_ReturnsValue() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + // Config op tooled over gRPC: StatusService.GetRuntimeParameter carries the proven 2020 GETRP + // request/response buffers unchanged inside the protobuf bytes fields. + HistorianClient client = new(BuildOptions(host)); + string? value = await client.GetRuntimeParameterAsync("HistorianVersion", CancellationToken.None); + Assert.False(string.IsNullOrWhiteSpace(value)); + } + + [Fact] + public async Task GetTagExtendedPropertiesAsync_OverGrpc_DoesNotThrow() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag) + || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + // Config op tooled over gRPC: RetrievalService.GetTagExtendedPropertiesFromName carries the + // proven 2020 GetTepByNm buffers. A system tag may have no user-defined properties, so this + // asserts the call completes and returns a well-formed (possibly empty) list. + HistorianClient client = new(BuildOptions(host)); + IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(tag!, CancellationToken.None); + Assert.NotNull(props); + } + + [Fact] + public async Task ExecuteSqlCommandAsync_OverGrpc_IsServerWalled() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + // ExecuteSqlCommand request rides the gRPC front door, but the server-side + // CSrvDbConnection.ExecuteSqlCommand faults (IndexOutOfRange / native error 38) — an unmet + // DB-connection precondition the pure managed gRPC session doesn't establish (captured + // 2026-06-22). The SDK surfaces this as ProtocolEvidenceMissingException. This test pins the + // wall so a future server/registration change that lifts it is noticed. + HistorianClient client = new(BuildOptions(host)); + await Assert.ThrowsAsync(() => client.ExecuteSqlCommandAsync( + "SELECT 10 AS Num, 'alpha' AS Word UNION ALL SELECT 20, NULL", + cancellationToken: CancellationToken.None)); + } + + [Fact] + public async Task TagWriteLifecycle_OverGrpc_CreatesAddsPropRenamesDeletes() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + // DESTRUCTIVE: gated on a dedicated sandbox-tag name so it never mutates a server by accident. + // Set HISTORIAN_GRPC_WRITE_SANDBOX_TAG to a throwaway tag name the test may create/rename/delete. + string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_WRITE_SANDBOX_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandbox) + || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + // Exercises the full gRPC tag-config write surface end-to-end against a write-enabled (0x401) + // session, then cleans up after itself: EnsureTags -> AddTagExtendedProperties -> + // (read-back verify) -> StartJob rename -> DeleteTags. + HistorianClient client = new(BuildOptions(host)); + string renamed = sandbox + "_R"; + + try + { + bool created = await client.EnsureTagAsync( + new HistorianTagDefinition { TagName = sandbox!, DataType = HistorianDataType.Float, EngineeringUnit = "u", MaxEU = 100 }, + CancellationToken.None); + Assert.True(created, "EnsureTags over gRPC should create the sandbox tag."); + + bool propAdded = await client.AddTagExtendedPropertyAsync(sandbox!, "GrpcToolingTest", "ok", CancellationToken.None); + Assert.True(propAdded, "AddTagExtendedProperties over gRPC should succeed."); + + IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None); + Assert.Contains(props, p => string.Equals(p.Name, "GrpcToolingTest", StringComparison.OrdinalIgnoreCase)); + + HistorianTagRenameResult rename = await client.RenameTagsAsync([(sandbox!, renamed)], CancellationToken.None); + Assert.True(rename.Accepted, $"StartJob rename over gRPC should be accepted: {rename.Error}"); + } + finally + { + // Best-effort cleanup of whichever name survives (rename is an async server job). + try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* ignore */ } + try { await client.DeleteTagAsync(renamed, CancellationToken.None); } catch { /* ignore */ } + } + } + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); From e7a6cf19892b5ee580c234e05ba6f49111912a2e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 01:26:34 -0400 Subject: [PATCH 3/4] docs(grpc): reflect newly-tooled config ops in the transport matrix - GetRuntimeParameter / GetTagExtendedProperties now live-verified over gRPC - ExecuteSqlCommand marked server-walled (new legend state) - tag-config writes marked sandbox-gated (new legend state) - document the HISTORIAN_GRPC_WRITE_SANDBOX_TAG live-test gate - rewrite the matrix summary to reflect what was learned tooling the config ops Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- README.md | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3e95762..09ab836 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,11 @@ Open2 buffers and SSPI tokens on both — on gRPC they simply ride inside protob `bytes` fields — so reads are at parity. The surfaces diverge at the edges. Legend: ✅ tooled + live-verified · ⚠️ tooled, partial/synthesized · -🔌 **the gRPC server exposes the RPC (recovered in `Grpc/Protos/*.proto`) but the -SDK doesn't drive it yet** — untooled/uncaptured, *not* a protocol gap · +🧪 tooled + routed but **sandbox-gated** (mutates server state, not yet run +destructively against a live box) · 🔌 **the gRPC server exposes the RPC +(recovered in `Grpc/Protos/*.proto`) but the SDK doesn't drive it yet** — +untooled/uncaptured, *not* a protocol gap · ⛔ tooled but **server-walled** (the +request rides the RPC but the server faults on an unmet precondition) · ❌ unavailable on that transport. | Operation | WCF | gRPC | Notes | @@ -82,27 +85,32 @@ SDK doesn't drive it yet** — untooled/uncaptured, *not* a protocol gap · | `AddHistoricalValuesAsync` | ❌ | ✅ | historical/backfill writes ride `HistoryService.AddStreamValues`; non-gRPC throws `ProtocolEvidenceMissingException` | | `GetServerTimeZoneAsync` | ❌ | ✅ | 2020 `GetSystemTimeZoneName` is a client-side stub (empty); WCF throws | | `GetStoreForwardStatusAsync` | ⚠️ | ✅ | gRPC contacts the server (measured idle-state, reports `ErrorOccurred`); WCF returns synthesized all-false. Active-SF magnitude is D2-gated on both | -| `ReadEventsAsync` | ✅ | 🔌 | gRPC `RetrievalService.StartEventQuery` / `GetNextEventQueryResultBuffer` / `EndEventQuery` recovered (`bytes btRequest` + handle); not tooled over gRPC | +| `GetRuntimeParameterAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`StatusService.GetRuntimeParameter`, the 2020 `GETRP` buffers ride unchanged) | +| `GetTagExtendedPropertiesAsync` | ✅ | ✅ | tooled + live-verified over gRPC (`RetrievalService.GetTagExtendedPropertiesFromName`, the `GetTepByNm` buffers ride unchanged) | +| `ExecuteSqlCommandAsync` | ✅ | ⛔ | gRPC request rides `RetrievalService.ExecuteSqlCommand`, but the server-side `CSrvDbConnection.ExecuteSqlCommand` faults (`IndexOutOfRange`, native err 38) — an unmet DB-connection precondition; bounded behind `ProtocolEvidenceMissingException`. Use WCF | +| `ReadEventsAsync` | ✅ | 🔌 | gRPC `StartEventQuery`/`GetNextEventQueryResultBuffer`/`EndEventQuery` recovered, but the read needs the full CM_EVENT registration state machine (RTag2+EnsT2) ported — not yet tooled | | `SendEventAsync` | ✅ | 🔌 | rides `AddStreamValues` family; no distinct event-send RPC, framing uncaptured over gRPC | -| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | 🔌 | gRPC `HistoryService.EnsureTags` / `DeleteTags` / `StartJob`(+`GetJobStatus`) recovered (`bytes btTagInfos`/`btTagnames`/`btInput` + handle) | -| `GetTagExtendedPropertiesAsync` / `AddTagExtendedPropertiesAsync` | ✅ | 🔌 | gRPC `RetrievalService.GetTagExtendedPropertiesFromName` + `HistoryService.AddTagExtendedProperties`; gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) | -| `ExecuteSqlCommandAsync` | ✅ | 🔌 | gRPC `RetrievalService.ExecuteSqlCommand` (`StrCommand` + `uiOption`, mirrors WCF `ExeC`/`GetR`) | -| `GetRuntimeParameterAsync` | ✅ | 🔌 | gRPC `StatusService.GetRuntimeParameter` (`bytes btRequest` + handle) | +| `EnsureTagAsync` / `DeleteTagAsync` / `RenameTagsAsync` | ✅ | 🧪 | tooled + routed over gRPC (`HistoryService.EnsureTags` / `DeleteTags` / `StartJob`, write-enabled 0x401 session, WCF serializers reused); sandbox-gated — not yet run destructively against a live box | +| `AddTagExtendedPropertiesAsync` | ✅ | 🧪 | tooled + routed over gRPC (`HistoryService.AddTagExtendedProperties`, write-enabled session); sandbox-gated. gRPC also exposes `DeleteTagExtendedProperties` (WCF delete was server-blocked) | | `GetConnectionStatusAsync` | ✅ | ❌ | synthesized from an authenticated probe — no dedicated RPC on either transport (gRPC `PingServer`/`GetHistorianConsoleStatus` could synthesize it) | | `ReadBlocksAsync` | ❌ | ❌ | `StartBlockRetrievalQuery` never captured on either transport — throws `ProtocolEvidenceMissingException` | In short: **WCF is the broad, mature surface** (every config write, events, SQL, and all reads), while **gRPC is the narrower *tooled* surface** — but the 2023 R2 -gRPC *contract* is actually a **superset** of WCF. Every 🔌 row above has a -recovered RPC carrying the **same opaque `bytes` buffers the existing WCF -serializers already emit**, keyed by the same `strHandle`/`uiHandle` session -handle the gRPC read path already obtains. So these are **capture-and-wire** items -(route the existing serializer into a gRPC orchestrator + golden-capture the -framing), **not** protocol-discovery items. We have only *buffer-verified* two -gRPC families live — the read chain and `AddStreamValues` — so per the -"capture first, never guess wire bytes" rule the 🔌 rows stay untooled until each -is captured. The natural production pattern today remains WCF for config/reads and -`RemoteGrpc` reserved for `AddHistoricalValuesAsync`. +gRPC *contract* is actually a **superset** of WCF. The recovered config RPCs carry +the **same opaque `bytes` buffers the existing WCF serializers already emit**, +keyed by the same `strHandle`/`uiHandle` session handle the read path obtains — +confirmed by tooling the read-side config ops (`GetRuntimeParameter`, +`GetTagExtendedProperties`) live: the WCF buffers ride the gRPC RPC unchanged and +the server accepts them. Two caveats surfaced when capturing the rest: `ExecuteSqlCommand` +is **server-walled** (the front-door `CSrvDbConnection` faults on a DB-connection +precondition the managed session doesn't establish — the same *class* of wall as +`OpenStorageConnection`), and `ReadEvents` needs the CM_EVENT registration state +machine ported. The remaining 🔌 rows are **capture-and-wire** items (route the +existing serializer into a gRPC orchestrator + live-capture), not +protocol-discovery — but per "capture first, never guess wire bytes" they stay +untooled until each is verified live. The natural production pattern today remains +WCF for config/writes and `RemoteGrpc` for reads + `AddHistoricalValuesAsync`. > A 2023 R2 server reports History interface version 12 (vs. 11 on 2020). The > connect-time version gate accepts both — they are byte-compatible — so gRPC @@ -216,6 +224,7 @@ $env:HISTORIAN_GRPC_TLS = 'true' # gRPC over TLS $env:HISTORIAN_GRPC_DNSID = 'my-2023r2-host' # cert DNS name when connecting by IP $env:HISTORIAN_GRPC_TIMEOUT = '120' # per-call deadline (s); raise for slow links $env:HISTORIAN_WRITE_SANDBOX_TAG = 'MyFloatTag' # gates the AddHistoricalValues write test +$env:HISTORIAN_GRPC_WRITE_SANDBOX_TAG = 'SandboxTag' # gates the DESTRUCTIVE tag create/rename/delete lifecycle test ``` The aggregate tests self-calibrate their query window from a real raw sample, so From 7e8bb07df3be66d940b5c5dbf360c1e46e21d8b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 01:30:04 -0400 Subject: [PATCH 4/4] docs(grpc): add gRPC tooling completion plan Self-contained plan for finishing gRPC surface parity: live-verify the sandbox-gated writes, port ReadEvents (CM_EVENT registration state machine), SendEvent (capture-blocked), the SQL server-wall stretch, and optional GetConnectionStatus. Includes the proven reuse pattern and live-verification setup so it survives context compaction. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- docs/plans/grpc-tooling-completion.md | 133 ++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/plans/grpc-tooling-completion.md diff --git a/docs/plans/grpc-tooling-completion.md b/docs/plans/grpc-tooling-completion.md new file mode 100644 index 0000000..5f58d45 --- /dev/null +++ b/docs/plans/grpc-tooling-completion.md @@ -0,0 +1,133 @@ +# gRPC Tooling Completion Plan + +Status as of 2026-06-22. Tracks the remaining work to finish tooling the AVEVA +Historian SDK's `RemoteGrpc` (2023 R2) transport so it reaches WCF surface parity. +Self-contained for pickup after context compaction. + +## Where things stand + +The gRPC transport already tools: probe, raw/aggregate/at-time reads, browse, +metadata, system-parameter, server time-zone, measured store-forward status, +`AddHistoricalValues` backfill write, **and** (newest, branch `grpc-config-ops`, +3 commits, NOT yet merged — `main` = `035d8a9`): + +- `GetRuntimeParameterAsync` — ✅ live-verified +- `GetTagExtendedPropertiesAsync` (read) — ✅ live-verified +- `ExecuteSqlCommandAsync` — ⛔ server-walled, bounded behind `ProtocolEvidenceMissingException` +- `EnsureTag` / `DeleteTag` / `RenameTags` / `AddTagExtendedProperties` — 🧪 tooled + routed, sandbox-gated, **not yet run destructively live** + +Test baseline: 317 offline green, 19 gRPC-live green. Relevant memory: +`project_grpc_config_ops_tooling`, `project_m0_grpc_parity`, +`project_roadmap_exhausted_2020wcf`, `reference_2023r2_live_server_access`, +`reference_wonder_sql_vd03_credentials`. + +## Proven pattern (reuse for everything below) + +A WCF config op is tooled over gRPC by reusing its **existing byte serializer/parser +verbatim** inside the protobuf `bytes` fields, keyed by the Open2 session handle: + +- `HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);` +- `HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, ct[, connectionMode]);` + - `session.StringHandle` = uppercase Open2 GUID → **string-handle** ops (Retrieval/Status/History string-handle RPCs). + - `session.ClientHandle` = transient `uint` → **uint-handle** ops (StartQuery, DeleteTags, GetNext*). + - write ops pass `connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode` (0x401). +- Call `new .Client(connection.Channel).(request, connection.Metadata, DateTime.UtcNow.Add(options.RequestTimeout), ct)`. +- Check `response.Status?.BSuccess`; decode error via `response.Status?.BtError` (hex = native byte0 0x84 + LE u32 code, often followed by facility/file/message ASCII — this decode cracked the SQL + extended-prop cases). +- The gRPC RetrievalService string-handle ops do NOT need the WCF `Retr.GetV` prime. + +Proto field-name reference and WCF serializer signatures: see the mapping captured +in `project_grpc_config_ops_tooling` memory and `Grpc/Protos/*.proto`. + +## Remaining items (priority order) + +### 1. Live-verify the write ops (cheapest, highest-confidence-gain) +- **Goal:** flip the 🧪 writes to ✅ by running the gated lifecycle test against a sandbox tag. +- **How:** set `HISTORIAN_GRPC_WRITE_SANDBOX_TAG` to a throwaway name and run + `TagWriteLifecycle_OverGrpc_CreatesAddsPropRenamesDeletes` against the live 2023 R2 box. +- **Risk/gotcha:** if any write is rejected, the first fix is to add the WCF write + **priming discovery-dance** (`HistorianWcfTagWriteOrchestrator.RunWritePriming`: + UpdC3 + 6 `GetSystemParameter` + `AllowRenameTags` + Trx/Stat/Retr `GetV`) to + `HistorianGrpcTagWriteOrchestrator` over the gRPC StatusService/HistoryService. + Rename also needs server `AllowRenameTags` enabled. Needs explicit user OK to + mutate the shared server (they previously chose "no live mutate"). +- **Files:** `tests/.../HistorianGrpcIntegrationTests.cs` (run only), + `src/.../Grpc/HistorianGrpcTagWriteOrchestrator.cs` (priming only if rejected). + +### 2. ReadEvents over gRPC (heaviest read op) +- **Goal:** route `ReadEventsAsync` over gRPC. +- **RPCs (exist):** `RetrievalService.StartEventQuery` (`uiHandle`, `uiQueryRequestType`, + `btRequest`) → `{Status, uiQueryHandle, btResonse}`; `GetNextEventQueryResultBuffer` + (`uiHandle`, `uiQueryHandle`) → `{Status, btResult}`; `EndEventQuery`. +- **Reuse:** `HistorianEventQueryProtocol.CreateStartEventQueryAttempts(...)` for the + request buffer (`QueryRequestTypeEvent`), `HistorianEventRowProtocol.Parse(...)` for rows. +- **The hard part — port the CM_EVENT registration state machine.** Without it, + `GetNextEventQueryResultBuffer` returns native error type=4 **code=85**. WCF does this + in `HistorianWcfEventOrchestrator.AddCmEventTagViaAddT`: UpdC3 → 6 system params → + `RegisterTags2` (CM_EVENT tag id `353b8145-5df0-4d46-a253-871aef49b321`, 24-byte + RTag2 buffer) → cross-service `GetV` → `EnsureTags2` (CM_EVENT CTagMetadata via + `HistorianAddTagsProtocol.SerializeCmEventCTagMetadata`). gRPC equivalents: + `HistoryService.RegisterTags`, `HistoryService.EnsureTags`, + `HistoryService.UpdateClientStatus`, `StatusService.GetSystemParameter`. +- **Approach:** new `Grpc/HistorianGrpcEventOrchestrator`. Open a read-only session, + replay the registration over gRPC (RegisterTags + EnsureTags + the discovery calls), + then run StartEventQuery → loop GetNextEventQueryResultBuffer → EndEventQuery, parsing + rows. Route in `Historian2020ProtocolDialect.ReadEventsAsync` on `UseGrpc`. +- **Verify:** live (read-only, safe) against the 2023 R2 box; dev box may return no + rows (env) — assert "no error 85 + chain completes," mirror the WCF event test. +- **Risk:** medium-high. Registration may need exact call ordering; capture the error + buffer (hex+ASCII) at each step if code 85 persists. + +### 3. SendEvent over gRPC +- **Goal:** route `SendEventAsync` over gRPC. +- **Blocker:** no distinct event-send RPC; WCF rides `AddStreamValues2` (the + `HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer` VTQ). The gRPC framing is + **uncaptured** — needs a native-client gRPC capture before implementing (per + "capture first, never guess"). Depends on #2 (same CM_EVENT registration). +- **Risk:** high / blocked on capture. Lowest priority. + +### 4. (Stretch) SQL server-wall investigation +- `ExecuteSqlCommand` over gRPC faults server-side in `CSrvDbConnection.ExecuteSqlCommand` + (IndexOutOfRange / native err 38) — a DB-connection precondition the managed session + doesn't establish. Next avenue: try a `HistoryService.RegisterTags`-family prime before + `ExecuteSqlCommand` (same fix that unblocked the M3 write path / OpenStorageConnection + class of wall). If it works, replace the bounded throw in `HistorianGrpcSqlClient` with + the real GetNextQueryResultBuffer fetch loop (already written there) and flip the test. + +### 5. (Optional) GetConnectionStatus over gRPC +- Currently WCF-only, synthesized from an authenticated probe (no dedicated RPC either + transport). Could synthesize the same over gRPC via `StatusService.PingServer` / + `GetHistorianConsoleStatus`. Low value; do only if parity is wanted. + +### Out of scope +- `ReadBlocks` (`StartBlockRetrievalQuery`) — never captured on either transport; leave + throwing `ProtocolEvidenceMissingException`. +- `DeleteTagExtendedProperties` — server-blocked on WCF (per-connection working set); + gRPC's single multiplexed channel *might* fix it — opportunistic probe only. + +## Live verification setup (every live run) + +Tunnel to `WONDER-SQL-VD03` must be up (gRPC `localhost:32565`, TLS, cert CN +`WONDER-SQL-VD03`; hosts entry present). Creds in gitignored `wonder-sql-vd03.txt` +(**QUOTED, colon-delimited** — strip quotes; use the `domainusername`/`domainpassword` +NAM domain account, which works for Historian gRPC; `wonderapp` does NOT). Env: + +``` +HISTORIAN_GRPC_HOST=wonder-sql-vd03 HISTORIAN_GRPC_PORT=32565 +HISTORIAN_GRPC_TLS=true HISTORIAN_GRPC_DNSID=WONDER-SQL-VD03 +HISTORIAN_USER= HISTORIAN_PASSWORD= +HISTORIAN_TEST_TAG=SysTimeSec +# writes only, destructive: HISTORIAN_GRPC_WRITE_SANDBOX_TAG= +# slow links: HISTORIAN_GRPC_TIMEOUT=120 +``` + +Run a subset: `dotnet test ./Histsdk.slnx --no-build --filter "FullyQualifiedName~"`. +Aggregate tests self-calibrate their window from a real raw sample (the box is idle/ +not-collecting). Sanitization scan before any commit: +`wonder-sql-vd03|zimmer|nam\\|dohertj2|ADOBuild` over commit-safe files. + +## Standing constraints +- Never commit credentials/hostnames/customer tag names/raw captures — placeholders only. +- `src/` stays pure managed .NET 10 (one allowed P/Invoke: SSPI). Never modify `current/` + or `aveva-install-*/`. +- Commit only when asked; branch first if on `main`; required footers + (Co-Authored-By + Claude-Session). Capture wire bytes before implementing — never guess.