using System.Diagnostics; using AVEVA.Historian.Client.Grpc; using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; 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; } } } // (A) WRITE REUSE VALIDITY: one externally-opened 0x401 (write-enabled) session, N writes on it via // RunWriteOnSession — NO Create()/handshake per write. If the server rejects reusing a write session, // write #2 throws -> RED finding. Both succeed -> GREEN (write-reuse is sound). Bounded writes to the // sandbox tag ONLY; latency LOGGED, success ASSERTED. [Fact] public void WriteEnabledSession_RunsTwoWrites_AllSucceed() { if (!TryGetWriteEnv(out string host, out string sandboxTag)) return; HistorianClientOptions options = BuildOptions(host); using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); HistorianGrpcHandshake.Session session = OpenWriteSession(options, connection); var writer = new HistorianGrpcHistoricalWriteOrchestrator(options); for (int i = 0; i < 2; i++) { var sw = Stopwatch.StartNew(); bool ok = writer.RunWriteOnSession(connection, session, sandboxTag, new[] { new HistorianHistoricalValue(DateTime.UtcNow.AddSeconds(-i), 1.0 + i, OpcQuality: 192) }, CancellationToken.None); sw.Stop(); _output.WriteLine($"reused-write[{i}] = {sw.ElapsedMilliseconds} ms, ok={ok}"); Assert.True(ok); } } // (B) READ-ON-WRITE-SESSION PROBE: can a 0x401 (write-enabled) session ALSO serve a raw read? Decides // the pool shape — ONE-KIND (a single write-enabled session serves both reads and writes) vs TWO-KIND // (separate read 0x402 / write 0x401 sessions). The kind is LOGGED, never asserted; any failure is // swallowed (a rejection is itself the finding, not a test failure). READ-ONLY here. [Fact] public void WriteEnabledSession_AlsoServesRead_RecordsKind() { if (!TryGetWriteEnv(out string host, out string sandboxTag)) return; HistorianClientOptions options = BuildOptions(host); using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); HistorianGrpcHandshake.Session session = OpenWriteSession(options, connection); (DateTime startUtc, DateTime endUtc) = LastSevenDays(); try { List rows = new HistorianGrpcReadOrchestrator(options) .RunRawQueryOnSession(connection, session.ClientHandle, sandboxTag, startUtc, endUtc, 8, CancellationToken.None); _output.WriteLine($"read-on-0x401 -> OK (rows={rows.Count}) => ONE-KIND pool (write-enabled serves reads)"); } catch (Exception ex) { _output.WriteLine($"read-on-0x401 -> FAILED ({ex.GetType().Name}) => TWO-KIND pool (separate read/write)"); } } // --- helpers --- private static bool TryGetWriteEnv(out string host, out string sandboxTag) { host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? ""; sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG") ?? ""; return !string.IsNullOrWhiteSpace(host) && !string.IsNullOrWhiteSpace(sandboxTag) && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")); } private static HistorianGrpcHandshake.Session OpenWriteSession(HistorianClientOptions o, HistorianGrpcConnection c) => HistorianGrpcHandshake.OpenSession(c, o, CancellationToken.None, connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode); 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 }; } }