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 }; } }