probe(grpc): DeleteTagExtendedProperties multiplexed-channel — disproven
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Outcome of the <see cref="ProbeDeleteTagExtendedPropertiesAsync"/> single-channel delete probe.</summary>
|
||||
/// <param name="Accepted">True if the server's <c>DelTep</c> returned success.</param>
|
||||
/// <param name="ErrorDescription">Decoded native error (byte0 0x84 + LE code + facility/file/message) when rejected.</param>
|
||||
/// <param name="TagInfoPrimeBytes">Bytes returned by the GetTgByNm prime (tag-identity working-set load).</param>
|
||||
/// <param name="ExtPropPrimePages">GetTepByNm prime pages that returned success (extended-property working-set load).</param>
|
||||
internal readonly record struct DeleteTagExtendedPropertiesProbeResult(
|
||||
bool Accepted, string? ErrorDescription, int TagInfoPrimeBytes, int ExtPropPrimePages);
|
||||
|
||||
/// <summary>
|
||||
/// <b>Reverse-engineering probe (not a public op).</b> Tests whether <c>DelTep</c>
|
||||
/// (DeleteTagExtendedProperties) — server-blocked on the 2020 WCF transport — succeeds over gRPC.
|
||||
/// The WCF failure is structural: the server's <c>CHistStorage::DeleteTagExtendedProperties</c>
|
||||
/// resolves each property from a <i>per-connection working set</i> the native client populates by
|
||||
/// multiplexing <c>GetTgByNm</c> + <c>GetTepByNm</c> + <c>DelTep</c> 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 <see cref="HistorianGrpcConnection.Channel"/>, 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.
|
||||
/// </summary>
|
||||
internal Task<DeleteTagExtendedPropertiesProbeResult> ProbeDeleteTagExtendedPropertiesAsync(
|
||||
string tagName, IReadOnlyList<string> 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<string> 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<HistorianTagRenameResult> RenameTagsAsync(
|
||||
IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
/// <summary>
|
||||
/// Executes a SQL command against the Historian over the WCF <c>ExeC</c>/<c>GetR</c> ops and
|
||||
|
||||
@@ -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<HistorianTagExtendedProperty> 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<HistorianTagExtendedProperty> 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");
|
||||
|
||||
Reference in New Issue
Block a user