using Xunit; namespace ZymonicServices.Performance.Tests; public class ResourceCacheTests { [Fact] public async Task ConcurrentConsumersShareOneCreationAndReleasedResourcesAreReused() { int creations = 0; using var cache = new ZymonicResourceCache(_ => { Interlocked.Increment(ref creations); return new Resource(); }); var leases = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => cache.AcquireAsync("same"))); var resource = leases[0].Resource; Assert.All(leases, lease => Assert.Same(resource, lease.Resource)); foreach (var lease in leases) lease.Dispose(); using var reused = await cache.AcquireAsync("same"); Assert.Same(resource, reused.Resource); Assert.Equal(1, creations); Assert.Equal(0, resource.Disposals); } [Fact] public async Task LeastRecentlyUsedUnusedResourceIsEvicted() { using var cache = new ZymonicResourceCache(_ => new Resource(), unusedCapacity: 2); var first = await cache.AcquireAsync("first"); first.Dispose(); var second = await cache.AcquireAsync("second"); second.Dispose(); (await cache.AcquireAsync("first")).Dispose(); var third = await cache.AcquireAsync("third"); third.Dispose(); Assert.Equal(0, first.Resource.Disposals); Assert.Equal(1, second.Resource.Disposals); Assert.Equal(0, third.Resource.Disposals); } [Fact] public async Task ActiveResourceSurvivesEvictionAndCacheDisposalUntilLastLeaseEnds() { using var cache = new ZymonicResourceCache(_ => new Resource(), unusedCapacity: 0); var first = await cache.AcquireAsync("active"); var second = await cache.AcquireAsync("active"); cache.Dispose(); first.Dispose(); first.Dispose(); Assert.Equal(0, second.Resource.Disposals); second.Dispose(); Assert.Equal(1, second.Resource.Disposals); await Assert.ThrowsAsync(() => cache.AcquireAsync("new")); } [Fact] public async Task CreationRunsOffSubscriberThreadAndOneAtATime() { using var release = new ManualResetEventSlim(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int active = 0; int maximumActive = 0; using var cache = new ZymonicResourceCache(_ => { int count = Interlocked.Increment(ref active); maximumActive = Math.Max(maximumActive, count); entered.TrySetResult(Environment.CurrentManagedThreadId); Assert.True(release.Wait(TimeSpan.FromSeconds(10))); Interlocked.Decrement(ref active); return new Resource(); }); int subscriber = Environment.CurrentManagedThreadId; var first = cache.AcquireAsync("first"); var second = cache.AcquireAsync("second"); try { Assert.NotEqual(subscriber, await entered.Task.WaitAsync(TimeSpan.FromSeconds(5))); Assert.False(first.IsCompleted); Assert.False(second.IsCompleted); } finally { release.Set(); } using var firstLease = await first.WaitAsync(TimeSpan.FromSeconds(5)); using var secondLease = await second.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(1, maximumActive); } [Fact] public async Task CancellingOneConsumerDoesNotCancelAnotherConsumersImage() { using var release = new ManualResetEventSlim(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var cache = new ZymonicResourceCache(_ => { entered.TrySetResult(); Assert.True(release.Wait(TimeSpan.FromSeconds(10))); return new Resource(); }); using var cancellation = new CancellationTokenSource(); var cancelled = cache.AcquireAsync("same", cancellation.Token); var retained = cache.AcquireAsync("same"); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => cancelled); } finally { release.Set(); } using var lease = await retained.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(0, lease.Resource.Disposals); } [Fact] public async Task CancelledQueuedResourceIsNotCreated() { using var release = new ManualResetEventSlim(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int unwantedCreations = 0; using var cache = new ZymonicResourceCache(key => { if (key == "block") { entered.TrySetResult(); Assert.True(release.Wait(TimeSpan.FromSeconds(10))); } if (key == "cancelled") Interlocked.Increment(ref unwantedCreations); return new Resource(); }); var blocking = cache.AcquireAsync("block"); using var cancellation = new CancellationTokenSource(); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var cancelled = cache.AcquireAsync("cancelled", cancellation.Token); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => cancelled); } finally { release.Set(); } using var first = await blocking.WaitAsync(TimeSpan.FromSeconds(5)); using var last = await cache.AcquireAsync("last").WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(0, unwantedCreations); } [Fact] public async Task ResourceCompletedAfterCancellationIsDisposed() { using var release = new ManualResetEventSlim(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var resource = new Resource(); using var cache = new ZymonicResourceCache(_ => { entered.TrySetResult(); Assert.True(release.Wait(TimeSpan.FromSeconds(10))); return resource; }); using var cancellation = new CancellationTokenSource(); var pending = cache.AcquireAsync("image", cancellation.Token); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => pending); cache.Dispose(); } finally { release.Set(); } await resource.Disposed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(1, resource.Disposals); } [Fact] public async Task FailedCreationCanBeRetried() { int attempts = 0; using var cache = new ZymonicResourceCache(_ => { if (Interlocked.Increment(ref attempts) == 1) throw new FormatException("Invalid resource"); return new Resource(); }); await Assert.ThrowsAsync(() => cache.AcquireAsync("same")); using var lease = await cache.AcquireAsync("same"); Assert.Equal(2, attempts); Assert.Equal(0, lease.Resource.Disposals); } [Fact] public async Task WeightLimitEvictsUnusedResourcesButPreservesActiveLeases() { using var cache = new ZymonicResourceCache(unusedCapacity: 32, getWeight: _ => 6, maxUnusedWeight: 10); var active = await cache.AcquireAsync("active", () => new Resource()); var first = await cache.AcquireAsync("first", () => new Resource()); first.Dispose(); var second = await cache.AcquireAsync("second", () => new Resource()); second.Dispose(); Assert.Equal(1, first.Resource.Disposals); Assert.Equal(0, active.Resource.Disposals); using (var reused = await cache.AcquireAsync("second", () => throw new Exception("Should reuse"))) Assert.Same(second.Resource, reused.Resource); active.Dispose(); Assert.Equal(1, second.Resource.Disposals); Assert.Equal(0, active.Resource.Disposals); } [Fact] public async Task OversizedUnusedResourceIsDisposedWithoutEvictingSmallerEntries() { var small = new Resource(); using var cache = new ZymonicResourceCache(getWeight: r => ReferenceEquals(r, small) ? 2 : 20, maxUnusedWeight: 10); (await cache.AcquireAsync("small", () => small)).Dispose(); var large = await cache.AcquireAsync("large", () => new Resource()); Assert.Equal(0, large.Resource.Disposals); large.Dispose(); Assert.Equal(1, large.Resource.Disposals); Assert.Equal(0, small.Disposals); } [Fact] public async Task PerRequestFactoryIsNotRetainedByCachedResource() { using var cache = new ZymonicResourceCache(); var payload = await PopulateWithCapturedPayload(cache); for (int i = 0; i < 3 && payload.IsAlive; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); } Assert.False(payload.IsAlive); using var retained = await cache.AcquireAsync("digest", () => throw new Exception("Should reuse")); } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private static async Task PopulateWithCapturedPayload(ZymonicResourceCache cache) { var payload = new byte[1_000_000]; var weak = new WeakReference(payload); using var lease = await cache.AcquireAsync("digest", () => { GC.KeepAlive(payload); return new Resource(); }); return weak; } private sealed class Resource : IDisposable { public int Disposals; public TaskCompletionSource Disposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public void Dispose() { Interlocked.Increment(ref Disposals); Disposed.TrySetResult(); } } }