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:
@@ -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