From 2bd86e4e830c96d317b79177e3bd5bb92f18cb3d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 06:55:05 -0400 Subject: [PATCH] =?UTF-8?q?probe(grpc):=20DeleteTagExtendedProperties=20mu?= =?UTF-8?q?ltiplexed-channel=20=E2=80=94=20disproven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an internal RE probe (HistorianGrpcTagWriteOrchestrator. ProbeDeleteTagExtendedPropertiesAsync) testing whether gRPC's single shared channel lifts the WCF per-connection working-set wall that blocks DelTep. Live result (2023 R2, History iface 12): both GetTgByNm + GetTepByNm primes succeed on the one shared channel, yet DelTep is still rejected (native code=1) and the property survives. So the working set is populated by the native client's in-process registration state, not the wire session — neither WCF's per-service channels nor gRPC's shared channel reproduce it. DelTep stays server-blocked on BOTH transports and remains unshipped. Gated negative test DeleteTagExtendedProperties_OverGrpc_ProbeMultiplexedChannel pins this (primes succeed, delete rejected, prop survives) and flips if a future server/registration lifts the wall. Comment in HistorianClient records the probe. 321 offline tests pass; live test passes bounded at ~11s. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../Grpc/HistorianGrpcTagWriteOrchestrator.cs | 109 ++++++++++++++++++ src/AVEVA.Historian.Client/HistorianClient.cs | 11 +- .../HistorianGrpcIntegrationTests.cs | 79 +++++++++++++ 3 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs index 480b991..3224032 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagWriteOrchestrator.cs @@ -2,6 +2,7 @@ using Google.Protobuf; using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; using GrpcHistory = ArchestrA.Grpc.Contract.History; +using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; namespace AVEVA.Historian.Client.Grpc; @@ -146,6 +147,114 @@ internal sealed class HistorianGrpcTagWriteOrchestrator return response.Status?.BSuccess ?? false; } + /// Outcome of the single-channel delete probe. + /// True if the server's DelTep returned success. + /// Decoded native error (byte0 0x84 + LE code + facility/file/message) when rejected. + /// Bytes returned by the GetTgByNm prime (tag-identity working-set load). + /// GetTepByNm prime pages that returned success (extended-property working-set load). + internal readonly record struct DeleteTagExtendedPropertiesProbeResult( + bool Accepted, string? ErrorDescription, int TagInfoPrimeBytes, int ExtPropPrimePages); + + /// + /// Reverse-engineering probe (not a public op). Tests whether DelTep + /// (DeleteTagExtendedProperties) — server-blocked on the 2020 WCF transport — succeeds over gRPC. + /// The WCF failure is structural: the server's CHistStorage::DeleteTagExtendedProperties + /// resolves each property from a per-connection working set the native client populates by + /// multiplexing GetTgByNm + GetTepByNm + DelTep over ONE physical connection. + /// The WCF SDK uses a separate channel per service, so the prime and the delete never share a + /// connection and the working set is empty at delete time (SErrorException). Over gRPC every service + /// client is built on the SAME , so this probe runs the + /// identical native sequence — GetTgByNm prime, GetTepByNm prime, then DelTep — on one write-enabled + /// (0x401) session/channel, to see whether the multiplexed channel satisfies the working-set check. + /// Returns the decoded outcome rather than throwing so the caller can record a positive or negative + /// result. See docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete. + /// + internal Task ProbeDeleteTagExtendedPropertiesAsync( + string tagName, IReadOnlyList propertyNames, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tagName); + ArgumentNullException.ThrowIfNull(propertyNames); + if (propertyNames.Count == 0) + { + throw new ArgumentException("At least one property name is required.", nameof(propertyNames)); + } + return Task.Run(() => ProbeDeleteTagExtendedProperties(tagName, propertyNames, cancellationToken), cancellationToken); + } + + private DeleteTagExtendedPropertiesProbeResult ProbeDeleteTagExtendedProperties( + string tagName, IReadOnlyList propertyNames, CancellationToken cancellationToken) + { + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, WriteEnabledConnectionMode); + + var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel); + var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); + DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout); + + // Prime 1 — GetTgByNm: load the tag identity into the storage session's working set (same channel). + int tagInfoPrimeBytes = 0; + try + { + GrpcRetrieval.GetTagInfosFromNameResponse tg = retrievalClient.GetTagInfosFromName( + new GrpcRetrieval.GetTagInfosFromNameRequest + { + StrHandle = session.StringHandle, + BtTagNames = ByteString.CopyFrom(HistorianGrpcTagClient.BuildTagNamesBuffer([tagName])), + UiSequence = 0 + }, + connection.Metadata, Deadline(), cancellationToken); + tagInfoPrimeBytes = tg.BtTagInfos?.Length ?? 0; + } + catch + { + // Best-effort prime; the delete still runs so the error buffer is captured. + } + + // Prime 2 — GetTepByNm: load the tag's extended properties into the working set (same channel), + // exactly as the native register->read->delete sequence does on its single connection. + int extPropPrimePages = 0; + byte[] tepRequest = HistorianTagExtendedPropertyProtocol.SerializeRequest(tagName); + uint sequence = 0; + for (int page = 0; page < 64; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse tep = retrievalClient.GetTagExtendedPropertiesFromName( + new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest + { + StrHandle = session.StringHandle, + BtTagNames = ByteString.CopyFrom(tepRequest), + UiSequence = sequence + }, + connection.Metadata, Deadline(), cancellationToken); + if (!(tep.Status?.BSuccess ?? false)) + { + break; + } + extPropPrimePages++; + if (HistorianTagExtendedPropertyProtocol.ParseResponse(tep.BtTeps?.ToByteArray() ?? []).Count == 0) + { + break; + } + sequence = tep.UiSequence; + } + + // DelTep on the SAME channel/session, while the priming reads are part of the same working set. + byte[] inBuff = HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest(tagName, propertyNames); + GrpcHistory.DeleteTagExtendedPropertiesResponse delete = historyClient.DeleteTagExtendedProperties( + new GrpcHistory.DeleteTagExtendedPropertiesRequest + { + StrHandle = session.StringHandle, + BtInput = ByteString.CopyFrom(inBuff) + }, + connection.Metadata, Deadline(), cancellationToken); + + bool accepted = delete.Status?.BSuccess ?? false; + string? errorDescription = accepted + ? null + : HistorianEventRegistrationProtocol.DescribeNativeError(delete.Status?.BtError?.ToByteArray() ?? []); + return new DeleteTagExtendedPropertiesProbeResult(accepted, errorDescription, tagInfoPrimeBytes, extPropPrimePages); + } + public Task RenameTagsAsync( IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken) { diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs index fd7f8d7..d2974bb 100644 --- a/src/AVEVA.Historian.Client/HistorianClient.cs +++ b/src/AVEVA.Historian.Client/HistorianClient.cs @@ -256,9 +256,14 @@ public sealed class HistorianClient : IAsyncDisposable // golden-verified against a server-accepted buffer, but the SDK cannot yet make the 2020 server // accept the delete: the server's CHistStorage::DeleteTagExtendedProperties consults a // per-connection working set that the native client populates by multiplexing GetTepByNm and - // DelTep over ONE connection, which the SDK's per-service WCF channels don't reproduce. See the - // documented-but-blocked path in HistorianWcfTagWriteOrchestrator and - // docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete. + // DelTep over ONE connection, which the SDK's per-service WCF channels don't reproduce. The gRPC + // transport — where every service client shares ONE channel — was probed 2026-06-22 to test that + // multiplexing hypothesis (GetTgByNm + GetTepByNm prime then DelTep on one write-enabled session, + // HistorianGrpcTagWriteOrchestrator.ProbeDeleteTagExtendedPropertiesAsync): both primes succeed on + // the shared channel yet the server STILL rejects the delete (native code=1), so gRPC does not lift + // the wall either. The working set is evidently populated by the native client's in-process + // registration state, not the wire session. See the documented-but-blocked path in + // HistorianWcfTagWriteOrchestrator and docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete. /// /// Executes a SQL command against the Historian over the WCF ExeC/GetR ops and diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index fd376f2..c724627 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -531,6 +531,85 @@ public sealed class HistorianGrpcIntegrationTests Assert.False(status.ConnectedToStoreForward); } + [Fact] + public async Task DeleteTagExtendedProperties_OverGrpc_ProbeMultiplexedChannel() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + // DESTRUCTIVE: reuses the write sandbox-tag gate so it never mutates a server by accident. + string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_WRITE_SANDBOX_TAG"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandbox) + || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) + { + return; + } + + // Out-of-scope opportunistic probe (grpc-tooling-completion.md). DelTep is server-blocked on the + // 2020 WCF transport because the server resolves the property from a per-CONNECTION working set + // that WCF's separate per-service channels can't populate (prime + delete land on different + // connections). gRPC builds every service client on ONE shared channel, so this probe runs the + // native GetTgByNm -> GetTepByNm -> DelTep sequence over a single write-enabled session to see + // whether the multiplexed channel satisfies the working-set check. + // + // RESULT — live 2026-06-22 (2023 R2 server, History iface 12): the multiplexed channel does NOT + // lift the wall. BOTH primes succeed on the shared channel (GetTgByNm returns the tag identity, + // GetTepByNm returns the property page), yet DelTep is still rejected with native error code=1 + // (the 5-byte buffer's byte0=132 is the universal 0x84 error marker, not a code) and the property + // survives. So DelTep stays server-blocked on BOTH transports — the working set the server + // consults is populated by something the SDK can't reproduce even over one connection (likely the + // native client's in-process registration object, not the wire session). This test PINS that + // negative: it asserts the probe is rejected and the property survives. If a future server or + // registration change lifts the wall, the inverted assertion flips and we notice. + HistorianClientOptions options = BuildOptions(host); + HistorianClient client = new(options); + const string propName = "GrpcDelProbe"; + + try + { + try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* clean slate */ } + + Assert.True(await client.EnsureTagAsync( + new HistorianTagDefinition { TagName = sandbox!, DataType = HistorianDataType.Float, EngineeringUnit = "u", MaxEU = 100 }, + CancellationToken.None), "EnsureTags should create the sandbox tag."); + Assert.True(await client.AddTagExtendedPropertyAsync(sandbox!, propName, "todelete", CancellationToken.None), + "AddTagExtendedProperties should seed the property to delete."); + + IReadOnlyList before = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None); + Assert.Contains(before, p => string.Equals(p.Name, propName, StringComparison.OrdinalIgnoreCase)); + + HistorianGrpcTagWriteOrchestrator orchestrator = new(options); + HistorianGrpcTagWriteOrchestrator.DeleteTagExtendedPropertiesProbeResult probe = + await orchestrator.ProbeDeleteTagExtendedPropertiesAsync(sandbox!, [propName], CancellationToken.None); + + // The primes must succeed (proving the single channel DID load the working set) — otherwise the + // negative below would be inconclusive (a prime failure, not a delete-path block). + Assert.True(probe.TagInfoPrimeBytes > 0, "GetTgByNm prime should return the tag identity on the shared channel."); + Assert.True(probe.ExtPropPrimePages > 0, "GetTepByNm prime should return the property page on the shared channel."); + + IReadOnlyList after = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None); + bool stillPresent = after.Any(p => string.Equals(p.Name, propName, StringComparison.OrdinalIgnoreCase)); + + // Pinned negative: the multiplexed channel does not lift the working-set wall. + Assert.False(probe.Accepted, + $"Unexpected: DelTep over gRPC was ACCEPTED — the multiplexed channel may now lift the wall. " + + $"Promote DeleteTagExtendedProperties to a public op. Probe={DescribeProbe(probe, stillPresent)}"); + Assert.True(stillPresent, + $"Unexpected: property was removed despite DelTep reporting failure. Probe={DescribeProbe(probe, stillPresent)}"); + } + finally + { + for (int i = 0; i < 5; i++) + { + try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* best-effort */ } + await Task.Delay(TimeSpan.FromSeconds(1)); + } + } + } + + private static string DescribeProbe( + HistorianGrpcTagWriteOrchestrator.DeleteTagExtendedPropertiesProbeResult probe, bool stillPresent) + => $"Accepted={probe.Accepted} StillPresent={stillPresent} TgPrimeBytes={probe.TagInfoPrimeBytes} " + + $"TepPrimePages={probe.ExtPropPrimePages} Err={probe.ErrorDescription}"; + private static HistorianClientOptions BuildOptions(string host) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");