232 lines
11 KiB
C#
232 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="HistorianGrpcIntegrationTests"/> (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).
|
|
/// </summary>
|
|
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<HistorianSample> 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<HistorianSample> 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<HistorianSample> 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
|
|
};
|
|
}
|
|
}
|