using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; /// /// ScadaBridge#67: evicts (and disposes) clients whose /// (site, endpoint) has gone unused for , /// so a renamed or removed address does not leave its channel resident for the life of the process. /// public class SiteStreamGrpcClientFactoryIdleEvictionTests { private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10); /// Test clock: only advances when told to. private sealed class ManualTimeProvider : TimeProvider { private long _ticks = TimeSpan.FromHours(1).Ticks; public override long TimestampFrequency => TimeSpan.TicksPerSecond; public override long GetTimestamp() => Interlocked.Read(ref _ticks); public void Advance(TimeSpan by) => Interlocked.Add(ref _ticks, by.Ticks); } private sealed class TrackingClient(string endpoint) : SiteStreamGrpcClient(endpoint) { public int DisposeCount; public override void Dispose() { Interlocked.Increment(ref DisposeCount); base.Dispose(); } public override ValueTask DisposeAsync() { Interlocked.Increment(ref DisposeCount); return base.DisposeAsync(); } } private sealed class Factory(ManualTimeProvider clock, TimeSpan ttl) : SiteStreamGrpcClientFactory( NullLoggerFactory.Instance, Options.Create(new CommunicationOptions { GrpcClientIdleTtl = ttl }), pskProvider: null, clock) { public int Created; protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint) { Interlocked.Increment(ref Created); return new TrackingClient(grpcEndpoint); } } /// An unused client is disposed and removed once the TTL elapses; a fresh one is created on next use. [Fact] public void IdleClient_IsEvictedAndDisposed_ThenRecreatedOnDemand() { var clock = new ManualTimeProvider(); using var factory = new Factory(clock, Ttl); var old = (TrackingClient)factory.GetOrCreate("site-a", "http://old:5100"); clock.Advance(Ttl + TimeSpan.FromSeconds(1)); Assert.Equal(1, factory.EvictIdle()); Assert.Equal(1, old.DisposeCount); Assert.Null(factory.TryGet("site-a", "http://old:5100")); var fresh = factory.GetOrCreate("site-a", "http://old:5100"); Assert.NotSame(old, fresh); } /// A client used within the TTL survives the sweep - and using it resets the idle clock. [Fact] public void RecentlyUsedClient_IsNotEvicted() { var clock = new ManualTimeProvider(); using var factory = new Factory(clock, Ttl); var kept = (TrackingClient)factory.GetOrCreate("site-a", "http://a:5100"); var idle = (TrackingClient)factory.GetOrCreate("site-a", "http://b:5100"); clock.Advance(TimeSpan.FromMinutes(8)); Assert.Same(kept, factory.GetOrCreate("site-a", "http://a:5100")); // touch clock.Advance(TimeSpan.FromMinutes(8)); Assert.Equal(1, factory.EvictIdle()); Assert.Equal(0, kept.DisposeCount); Assert.Equal(1, idle.DisposeCount); Assert.Same(kept, factory.TryGet("site-a", "http://a:5100")); } /// A client with a live subscription is never evicted, however long since it was handed out. [Fact] public void ClientWithActiveSubscription_IsNotEvicted() { var clock = new ManualTimeProvider(); using var factory = new Factory(clock, Ttl); var busy = (TrackingClient)factory.GetOrCreate("site-a", "http://a:5100"); using var cts = new CancellationTokenSource(); busy.RegisterSubscription("corr-1", cts); clock.Advance(Ttl * 3); Assert.Equal(0, factory.EvictIdle()); Assert.Equal(0, busy.DisposeCount); // Once the subscription is gone it becomes evictable again. busy.Unsubscribe("corr-1"); Assert.Equal(1, factory.EvictIdle()); Assert.Equal(1, busy.DisposeCount); } /// A zero TTL disables idle eviction entirely. [Fact] public void ZeroTtl_DisablesEviction() { var clock = new ManualTimeProvider(); using var factory = new Factory(clock, TimeSpan.Zero); var client = (TrackingClient)factory.GetOrCreate("site-a", "http://a:5100"); clock.Advance(TimeSpan.FromDays(30)); Assert.Equal(0, factory.EvictIdle()); Assert.Equal(0, client.DisposeCount); } /// Hand-out racing the sweep never throws and never returns null; evicted entries are re-created. [Fact] public async Task ConcurrentGetOrCreateAndEviction_IsThreadSafe() { var clock = new ManualTimeProvider(); using var factory = new Factory(clock, TimeSpan.FromSeconds(1)); var stop = false; var evictor = Task.Run(() => { while (!Volatile.Read(ref stop)) { clock.Advance(TimeSpan.FromSeconds(2)); factory.EvictIdle(); } }); var getters = Enumerable.Range(0, 4).Select(_ => Task.Run(() => { for (var i = 0; i < 20_000; i++) { Assert.NotNull(factory.GetOrCreate("site-a", "http://a:5100")); factory.TryGet("site-a", "http://a:5100"); } })).ToArray(); await Task.WhenAll(getters); Volatile.Write(ref stop, true); await evictor; Assert.True(factory.Created >= 1); } /// The validator rejects a negative idle TTL and accepts zero. [Fact] public void Validator_RejectsNegativeIdleTtl() { var validator = new CommunicationOptionsValidator(); var bad = validator.Validate(null, new CommunicationOptions { GrpcClientIdleTtl = TimeSpan.FromSeconds(-1) }); var zero = validator.Validate(null, new CommunicationOptions { GrpcClientIdleTtl = TimeSpan.Zero }); Assert.True(bad.Failed); Assert.Contains("GrpcClientIdleTtl", string.Join(" ", bad.Failures!)); Assert.DoesNotContain("GrpcClientIdleTtl", string.Join(" ", zero.Failures ?? [])); } }