test(spike): bound event read-after-send so the spike can't hang on C2 long-poll

The Q3 read-after-send probe (ReusedEventSession_ServesReadAfterSend_BestEffort)
long-polled GetNext to the no-data terminal with no tight bound and ran past the
5-min suite timeout on the live run. Bound it two ways: a read-only options copy
with a 5s RequestTimeout (so each GetNext RPC deadlines fast) and an 8s
CancellationToken passed as ct. Either fuse returns the method in ~10s; the
timeout/cancellation is logged as the expected C2-gated outcome (still no assert).
Other three spike methods unchanged.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-25 11:32:09 -04:00
parent 777a7700b4
commit 81da404c5d
@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using Grpc.Core;
using AVEVA.Historian.Client.Grpc; using AVEVA.Historian.Client.Grpc;
using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Models;
using Xunit; using Xunit;
@@ -104,7 +105,6 @@ public sealed class EventSessionReuseSpikeTests
if (!TryGetEnv(out string host, out string sandboxTag)) return; if (!TryGetEnv(out string host, out string sandboxTag)) return;
HistorianClientOptions options = BuildOptions(host); HistorianClientOptions options = BuildOptions(host);
var writeOrch = new HistorianGrpcEventWriteOrchestrator(options); var writeOrch = new HistorianGrpcEventWriteOrchestrator(options);
var readOrch = new HistorianGrpcEventOrchestrator(options);
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
HistorianGrpcHandshake.Session session = writeOrch.OpenAndRegisterEventSession(connection, CancellationToken.None); HistorianGrpcHandshake.Session session = writeOrch.OpenAndRegisterEventSession(connection, CancellationToken.None);
@@ -112,16 +112,34 @@ public sealed class EventSessionReuseSpikeTests
bool sent = writeOrch.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 0), CancellationToken.None); bool sent = writeOrch.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 0), CancellationToken.None);
_output.WriteLine($"seed-send before read-probe ok={sent}"); _output.WriteLine($"seed-send before read-probe ok={sent}");
// HARD bound so this probe CANNOT hang on the known C2 event-read long-poll (GetNext blocks to the
// no-data terminal on an idle box). Two independent fuses: (1) a read-only options copy with a 5s
// RequestTimeout so each underlying GetNext RPC deadlines quickly (the read orchestrator caps its
// poll deadline at min(10s, RequestTimeout)); (2) an 8s CancellationToken passed as ct so the chain
// is cancelled even if a per-RPC deadline is not honored over a tunnel. Whichever fires first, the
// method returns in ~10s max even when the read never returns data.
HistorianClientOptions readOptions = BuildOptions(host, requestTimeoutOverride: TimeSpan.FromSeconds(5));
var readOrch = new HistorianGrpcEventOrchestrator(readOptions);
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(8));
(DateTime startUtc, DateTime endUtc) = LastSevenDays(); (DateTime startUtc, DateTime endUtc) = LastSevenDays();
try try
{ {
List<HistorianEvent> rows = readOrch.RunEventQueryOnSession( List<HistorianEvent> rows = readOrch.RunEventQueryOnSession(
connection, session, startUtc, endUtc, filter: null, CancellationToken.None); connection, session, startUtc, endUtc, filter: null, readCts.Token);
_output.WriteLine($"read-after-send -> OK (rows={rows.Count}) => ONE-KIND (an Event session serves send AND read)"); _output.WriteLine($"read-after-send -> OK (rows={rows.Count}) => ONE-KIND (an Event session serves send AND read)");
} }
catch (Exception ex) when (ex is OperationCanceledException
|| (ex is RpcException rpc && rpc.StatusCode == StatusCode.DeadlineExceeded))
{
// EXPECTED outcome: the read hit its bound (CTS timeout or per-RPC deadline) without returning —
// consistent with the C2 event-read gating (GetNext long-polls to the no-data terminal). This is
// the recorded one-kind finding, NOT a failure.
_output.WriteLine($"read-after-send did not return within the bound (consistent with C2 event-read gating): {ex.GetType().Name}");
}
catch (Exception ex) catch (Exception ex)
{ {
// Reads are gated C2 — a rejection / long-poll terminal is itself the finding, NOT a failure. // Any other rejection on the reused session is also the finding, not a failure.
_output.WriteLine($"read-after-send -> swallowed ({ex.GetType().Name}: {ex.Message}) => read gated/unverified over gRPC (expected)"); _output.WriteLine($"read-after-send -> swallowed ({ex.GetType().Name}: {ex.Message}) => read gated/unverified over gRPC (expected)");
} }
// No assertion: this method's job is to RECORD the one-kind outcome for B0c, not gate on it. // No assertion: this method's job is to RECORD the one-kind outcome for B0c, not gate on it.
@@ -203,7 +221,9 @@ public sealed class EventSessionReuseSpikeTests
return (end - TimeSpan.FromDays(7), end); return (end - TimeSpan.FromDays(7), end);
} }
private static HistorianClientOptions BuildOptions(string host) // requestTimeoutOverride: when set, forces RequestTimeout (used by the read-after-send probe to give
// each GetNext RPC a short deadline). null preserves the env-driven default for the send/idle methods.
private static HistorianClientOptions BuildOptions(string host, TimeSpan? requestTimeoutOverride = null)
{ {
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"); string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
@@ -212,9 +232,10 @@ public sealed class EventSessionReuseSpikeTests
? parsed ? parsed
: HistorianClientOptions.DefaultGrpcPort; : HistorianClientOptions.DefaultGrpcPort;
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase); bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0 TimeSpan timeout = requestTimeoutOverride
?? (int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
? TimeSpan.FromSeconds(secs) ? TimeSpan.FromSeconds(secs)
: new HistorianClientOptions { Host = host }.RequestTimeout; : new HistorianClientOptions { Host = host }.RequestTimeout);
return new HistorianClientOptions return new HistorianClientOptions
{ {