From 899f9ccf6bec9ce1058e49b9b24022024af0c44d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 01:10:19 -0400 Subject: [PATCH] test(grpc): env-gated handshake-reuse spike (validity + latency + idle sweep) --- .../HandshakeReuseSpikeTests.cs | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs diff --git a/tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs new file mode 100644 index 0000000..0aba687 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs @@ -0,0 +1,168 @@ +using System.Diagnostics; +using AVEVA.Historian.Client.Grpc; +using AVEVA.Historian.Client.Models; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// SPIKE (pending.md A1): does the 2023 R2 historian honor REUSING one authenticated session across +/// many operations — the precondition for handshake amortization? Env-gated exactly like +/// (silent skip without HISTORIAN_GRPC_HOST + +/// HISTORIAN_TEST_TAG + HISTORIAN_USER). READ-ONLY — no writes, safe against live data. Reuse +/// validity is ASSERTED; latency numbers are LOGGED only (no flaky perf gates). +/// +public sealed class HandshakeReuseSpikeTests +{ + private const int ReuseOps = 5; + private readonly ITestOutputHelper _output; + + public HandshakeReuseSpikeTests(ITestOutputHelper output) => _output = output; + + // (1) REUSE VALIDITY: one session, N reads on the same clientHandle. If the server rejects handle + // reuse, read #2+ throws -> RED finding. All succeed -> GREEN finding. + [Fact] + public void ReusedSession_RunsManyReads_AllSucceed() + { + if (!TryGetEnv(out string host, out string tag)) return; + HistorianClientOptions options = BuildOptions(host); + (DateTime startUtc, DateTime endUtc) = LastSevenDays(); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + + var swHandshake = Stopwatch.StartNew(); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None); + swHandshake.Stop(); + _output.WriteLine($"open-session (handshake) = {swHandshake.ElapsedMilliseconds} ms"); + + var orchestrator = new HistorianGrpcReadOrchestrator(options); + for (int i = 0; i < ReuseOps; i++) + { + var sw = Stopwatch.StartNew(); + List rows = orchestrator.RunRawQueryOnSession( + connection, session.ClientHandle, tag, startUtc, endUtc, maxValues: 8, CancellationToken.None); + sw.Stop(); + _output.WriteLine($"reused-read[{i}] = {sw.ElapsedMilliseconds} ms, rows={rows.Count}"); + Assert.NotEmpty(rows); + Assert.All(rows, r => Assert.Equal(tag, r.TagName)); + } + } + + // (2) WIN MAGNITUDE: full per-call path (fresh Create+handshake+query) vs amortized (one handshake, + // N reused reads). Logged only — quantifies whether amortization is worth the refactor. + [Fact] + public async Task ReusedSession_VsPerCallPath_LogsLatencyDelta() + { + if (!TryGetEnv(out string host, out string tag)) return; + HistorianClientOptions options = BuildOptions(host); + (DateTime startUtc, DateTime endUtc) = LastSevenDays(); + + HistorianClient client = new(options); + var swPerCall = Stopwatch.StartNew(); + for (int i = 0; i < ReuseOps; i++) + { + await foreach (HistorianSample _ in client.ReadRawAsync(tag, startUtc, endUtc, 8, CancellationToken.None)) { } + } + swPerCall.Stop(); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None); + var orchestrator = new HistorianGrpcReadOrchestrator(options); + var swAmortized = Stopwatch.StartNew(); + for (int i = 0; i < ReuseOps; i++) + { + orchestrator.RunRawQueryOnSession(connection, session.ClientHandle, tag, startUtc, endUtc, 8, CancellationToken.None); + } + swAmortized.Stop(); + + _output.WriteLine($"per-call ({ReuseOps} ops) = {swPerCall.ElapsedMilliseconds} ms"); + _output.WriteLine($"amortized ({ReuseOps} ops) = {swAmortized.ElapsedMilliseconds} ms"); + _output.WriteLine($"saving over {ReuseOps} ops = {swPerCall.ElapsedMilliseconds - swAmortized.ElapsedMilliseconds} ms"); + } + + // (3) EXPIRY SWEEP: reuse after idle gaps. Default bounded [0s, 30s]; longer tiers opt-in via + // HISTORIAN_REUSE_IDLE_SECONDS="0,30,120,600". Rethrows at the tier where reuse first breaks. + [Fact] + public void ReusedSession_IdleSweep_SurfacesExpiryTier() + { + if (!TryGetEnv(out string host, out string tag)) return; + HistorianClientOptions options = BuildOptions(host); + (DateTime startUtc, DateTime endUtc) = LastSevenDays(); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None); + var orchestrator = new HistorianGrpcReadOrchestrator(options); + + foreach (int delaySec in ParseIdleSweep()) + { + if (delaySec > 0) Thread.Sleep(TimeSpan.FromSeconds(delaySec)); + try + { + List rows = orchestrator.RunRawQueryOnSession( + connection, session.ClientHandle, tag, startUtc, endUtc, 8, CancellationToken.None); + _output.WriteLine($"idle {delaySec,4}s -> OK (rows={rows.Count})"); + } + catch (Exception ex) + { + _output.WriteLine($"idle {delaySec,4}s -> BROKE ({ex.GetType().Name})"); + throw; + } + } + } + + // --- helpers --- + + private static bool TryGetEnv(out string host, out string tag) + { + host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? ""; + tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG") ?? ""; + return !string.IsNullOrWhiteSpace(host) + && !string.IsNullOrWhiteSpace(tag) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")); + } + + private static (DateTime StartUtc, DateTime EndUtc) LastSevenDays() + { + DateTime end = DateTime.UtcNow; + return (end - TimeSpan.FromDays(7), end); + } + + private static int[] ParseIdleSweep() + { + string? raw = Environment.GetEnvironmentVariable("HISTORIAN_REUSE_IDLE_SECONDS"); + if (string.IsNullOrWhiteSpace(raw)) return [0, 30]; + return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.Parse(s, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); + } + + 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); + // Optional per-call deadline override (seconds) for slow/remote boxes — heavier aggregate + // modes over a tunnelled link can exceed the 30s default. Falls back to the SDK default. + 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 // the stock client always advertises grpc gzip request encoding + }; + } +}