From 777a7700b4d199b444bcbfbef595714131e37a7a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 11:02:34 -0400 Subject: [PATCH] test(spike): event-session reuse spike harness (env-gated, B0b) Opens one v8 Event session and measures SendEvent reuse (register-once, send-many) + best-effort read-after-send + optional idle sweep. Skips offline; run live in B0c to gate event amortization (pending.md A1 broadening, Stage B0). Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../EventOnSessionSeamTests.cs | 1 + .../EventSessionReuseSpikeTests.cs | 234 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs diff --git a/tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs b/tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs index 05e8688..4f8311f 100644 --- a/tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs @@ -36,6 +36,7 @@ public class EventOnSessionSeamTests MethodInfo m = RequireMethod(typeof(HistorianGrpcEventWriteOrchestrator), "OpenAndRegisterEventSession"); ParameterInfo[] ps = m.GetParameters(); Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name); + Assert.Equal("CancellationToken", ps[1].ParameterType.Name); Assert.Equal("Session", m.ReturnType.Name); } diff --git a/tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs new file mode 100644 index 0000000..7dcb58c --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs @@ -0,0 +1,234 @@ +using System.Diagnostics; +using AVEVA.Historian.Client.Grpc; +using AVEVA.Historian.Client.Models; +using Xunit; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// SPIKE (pending.md A1 broadening, Stage B0b): can ONE v8 Event session be REUSED across many event +/// ops without re-handshaking — the precondition for broadening handshake amortization to the event +/// path? Env-gated exactly like (silent early-return skip +/// without HISTORIAN_GRPC_HOST + HISTORIAN_USER + HISTORIAN_PASSWORD + HISTORIAN_EVENT_SANDBOX_TAG). +/// +/// This is the B0b HARNESS only — it is RUN LIVE by a human over VPN in B0c. It SKIPS cleanly offline +/// (no historian contacted, no event sent). It drives the B0a internal seams directly: +/// (open v8 Event session +/// + RegisterCmEventTag ONCE) and +/// (send-only, on the externally-supplied warm session). +/// +/// Spike questions (priority order), mapped to the test methods below: +/// (1) Does a v8 Event session survive REUSE? — +/// (PRIMARY GREEN/RED signal: two sends on one session both succeed; the 2nd skips ECDH+register). +/// (2) Does REGISTER-ONCE work? — +/// (OpenAndRegister once, then SendEventOnSession N× — no per-send re-registration). +/// (3) ONE-KIND best-effort — +/// (can the same session also serve a ReadEvents after a send? LOGGED, never asserted — reads are gated C2). +/// (4) IDLE expiry best-effort — +/// (how long can the session sit idle before a send breaks? LOGGED, never asserted). +/// +/// SAFETY: every send targets the env var HISTORIAN_EVENT_SANDBOX_TAG ONLY (carried as the event +/// SourceName/Type so the appended events are unambiguously attributable to the sandbox identity, never +/// a production tag). Success is ASSERTED for (1)/(2); latency is LOGGED only (no flaky perf gates). +/// +public sealed class EventSessionReuseSpikeTests +{ + private const int SendMany = 3; + private readonly ITestOutputHelper _output; + + public EventSessionReuseSpikeTests(ITestOutputHelper output) => _output = output; + + // (1) REUSE VALIDITY — PRIMARY signal. Open+register ONE v8 Event session, then SendEventOnSession + // TWICE on it with NO re-handshake/re-register between sends. If the server rejects reusing a v8 + // Event session, send #2 fails (false / throws) -> RED finding. Both succeed -> GREEN (event-session + // reuse is sound, the precondition for event amortization). Latency LOGGED so B0c sees the win + // (open+register cost vs the two reused sends). + [Fact] + public void ReusedEventSession_SendsTwice_SecondSkipsHandshake() + { + if (!TryGetEnv(out string host, out string sandboxTag)) return; + HistorianClientOptions options = BuildOptions(host); + var orchestrator = new HistorianGrpcEventWriteOrchestrator(options); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + + var swOpen = Stopwatch.StartNew(); + HistorianGrpcHandshake.Session session = orchestrator.OpenAndRegisterEventSession(connection, CancellationToken.None); + swOpen.Stop(); + _output.WriteLine($"open+register (ECDH handshake + RegisterCmEventTag) = {swOpen.ElapsedMilliseconds} ms"); + _output.WriteLine($"registration diag: {orchestrator.RegistrationDiag}"); + + for (int i = 0; i < 2; i++) + { + HistorianEvent evt = BuildSandboxEvent(sandboxTag, attempt: i); + var sw = Stopwatch.StartNew(); + bool ok = orchestrator.SendEventOnSession(connection, session, evt, CancellationToken.None); + sw.Stop(); + _output.WriteLine($"reused-send[{i}] = {sw.ElapsedMilliseconds} ms, ok={ok}, lastErr='{orchestrator.LastSendErrorDescription}'"); + Assert.True(ok, $"reused v8 Event session send[{i}] should be accepted (AddStreamValues BSuccess)."); + } + } + + // (2) REGISTER-ONCE. Open+register ONCE, then SendEventOnSession N× — proving RegisterCmEventTag does + // NOT need re-running per send (the seam's whole point). All sends must succeed. + [Fact] + public void ReusedEventSession_RegisterOnce_ThenSendMany() + { + if (!TryGetEnv(out string host, out string sandboxTag)) return; + HistorianClientOptions options = BuildOptions(host); + var orchestrator = new HistorianGrpcEventWriteOrchestrator(options); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = orchestrator.OpenAndRegisterEventSession(connection, CancellationToken.None); + + for (int i = 0; i < SendMany; i++) + { + HistorianEvent evt = BuildSandboxEvent(sandboxTag, attempt: i); + var sw = Stopwatch.StartNew(); + bool ok = orchestrator.SendEventOnSession(connection, session, evt, CancellationToken.None); + sw.Stop(); + _output.WriteLine($"register-once send[{i}] = {sw.ElapsedMilliseconds} ms, ok={ok}"); + Assert.True(ok, $"register-once send[{i}] should be accepted without per-send re-registration."); + } + } + + // (3) ONE-KIND PROBE (best-effort). After a send on the warm session, try a ReadEvents on the SAME + // session. Event reads are GATED (C2 — the gRPC server long-polls GetNext to the no-data terminal and + // row-level retrieval is not verified over gRPC), so the outcome (rows or exception) is LOGGED, never + // asserted: the test passes as long as the catch swallows any failure. Records the one-kind finding + // (can one Event session serve both send and read?) for B0c. + [Fact] + public void ReusedEventSession_ServesReadAfterSend_BestEffort() + { + if (!TryGetEnv(out string host, out string sandboxTag)) return; + HistorianClientOptions options = BuildOptions(host); + var writeOrch = new HistorianGrpcEventWriteOrchestrator(options); + var readOrch = new HistorianGrpcEventOrchestrator(options); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = writeOrch.OpenAndRegisterEventSession(connection, CancellationToken.None); + + bool sent = writeOrch.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 0), CancellationToken.None); + _output.WriteLine($"seed-send before read-probe ok={sent}"); + + (DateTime startUtc, DateTime endUtc) = LastSevenDays(); + try + { + List rows = readOrch.RunEventQueryOnSession( + connection, session, startUtc, endUtc, filter: null, CancellationToken.None); + _output.WriteLine($"read-after-send -> OK (rows={rows.Count}) => ONE-KIND (an Event session serves send AND read)"); + } + catch (Exception ex) + { + // Reads are gated C2 — a rejection / long-poll terminal is itself the finding, NOT a failure. + _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. + } + + // (4) IDLE-EXPIRY SWEEP (best-effort, log-only). Send, sit idle for a gap, send again; LOG whether the + // 2nd send broke (and after how long). Bounds how long a warm Event session may sit idle before the + // server expires it — informs the keepalive cadence for an event-session pool. Default gap 25s; + // override via HISTORIAN_EVENT_IDLE_SECONDS. NEVER asserted (a break is the finding, not a failure). + [Fact] + [Trait("Category", "LiveSpike")] + public void ReusedEventSession_IdleSweep_BestEffort() + { + if (!TryGetEnv(out string host, out string sandboxTag)) return; + HistorianClientOptions options = BuildOptions(host); + var orchestrator = new HistorianGrpcEventWriteOrchestrator(options); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = orchestrator.OpenAndRegisterEventSession(connection, CancellationToken.None); + + bool first = orchestrator.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 0), CancellationToken.None); + _output.WriteLine($"idle-sweep first send ok={first}"); + + int idleSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_EVENT_IDLE_SECONDS"), out int parsed) && parsed > 0 + ? parsed + : 25; + _output.WriteLine($"idle-sweep: sleeping {idleSec}s before the second send..."); + Thread.Sleep(TimeSpan.FromSeconds(idleSec)); + + try + { + bool second = orchestrator.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 1), CancellationToken.None); + _output.WriteLine($"idle {idleSec}s -> second send ok={second} (session {(second ? "SURVIVED" : "rejected")} the idle gap)"); + } + catch (Exception ex) + { + _output.WriteLine($"idle {idleSec}s -> second send BROKE ({ex.GetType().Name}: {ex.Message}) — session expired while idle"); + } + // No assertion: idle-expiry timing is a LOGGED finding for the keepalive cadence, not a gate. + } + + // --- helpers --- + + // Build a send event that targets the sandbox identity ONLY. The CM_EVENT send buffer carries no + // per-tag routing field (it registers against the CM_EVENT system tag), so we stamp the sandbox tag + // NAME into SourceName + Type and a marker Property so the appended event is unambiguously + // attributable to the sandbox — never a production tag. A fresh Id/timestamps per attempt. + private static HistorianEvent BuildSandboxEvent(string sandboxTag, int attempt) + { + DateTime now = DateTime.UtcNow; + return new HistorianEvent( + Id: Guid.NewGuid(), + EventTimeUtc: now.AddSeconds(-attempt), + ReceivedTimeUtc: now, + Type: sandboxTag, + SourceName: sandboxTag, + Namespace: "HistGW.EventReuseSpike", + RevisionVersion: 0, + Properties: new Dictionary + { + ["SpikeAttempt"] = attempt.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["SpikeMarker"] = "B0b-event-session-reuse", + }); + } + + private static bool TryGetEnv(out string host, out string sandboxTag) + { + host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? ""; + sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_EVENT_SANDBOX_TAG") ?? ""; + return !string.IsNullOrWhiteSpace(host) + && !string.IsNullOrWhiteSpace(sandboxTag) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD")); + } + + private static (DateTime StartUtc, DateTime EndUtc) LastSevenDays() + { + DateTime end = DateTime.UtcNow; + return (end - TimeSpan.FromDays(7), end); + } + + private static HistorianClientOptions BuildOptions(string host) + { + string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); + string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"); + bool explicitCreds = !string.IsNullOrEmpty(user); + int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed) + ? parsed + : HistorianClientOptions.DefaultGrpcPort; + 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.FromSeconds(secs) + : new HistorianClientOptions { Host = host }.RequestTimeout; + + return new HistorianClientOptions + { + Host = host, + Port = port, + Transport = HistorianTransport.RemoteGrpc, + GrpcUseTls = tls, + AllowUntrustedServerCertificate = tls, + ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"), + IntegratedSecurity = !explicitCreds, + UserName = user ?? string.Empty, + Password = password ?? string.Empty, + RequestTimeout = timeout, + Compression = true + }; + } +}