|
| 1 | +using FluentAssertions; |
| 2 | +using Hl7.Fhir.Model; |
| 3 | +using LantanaGroup.Link.Shared.Application.Enums; |
| 4 | +using LantanaGroup.Link.Shared.Application.Interfaces; |
| 5 | +using LantanaGroup.Link.Shared.Application.Models.Configs; |
| 6 | +using LantanaGroup.Link.Shared.Application.Services.ResourceCache; |
| 7 | +using Microsoft.Extensions.Logging; |
| 8 | +using Microsoft.Extensions.Options; |
| 9 | +using Moq; |
| 10 | +using StackExchange.Redis; |
| 11 | + |
| 12 | +namespace UnitTests.Shared.ResourceCache; |
| 13 | + |
| 14 | +/// <summary> |
| 15 | +/// Covers <see cref="HybridResourceCache"/>'s Redis-vs-ABS selection, which since LEGLINK-770 |
| 16 | +/// derives the max-memory denominator from configuration (<see cref="ResourceCacheRedisSettings.MaxMemoryBytes"/>) |
| 17 | +/// rather than Redis <c>INFO maxmemory</c> (Azure Managed Redis does not return it). |
| 18 | +/// </summary> |
| 19 | +[Trait("Category", "UnitTests")] |
| 20 | +public class HybridResourceCacheTests |
| 21 | +{ |
| 22 | + private readonly Mock<IResourceCache> _redisCache = new(); |
| 23 | + private readonly Mock<IResourceCache> _absCache = new(); |
| 24 | + private readonly Mock<IConnectionMultiplexer> _multiplexer = new(); |
| 25 | + private readonly Mock<ILogger<HybridResourceCache>> _logger = new(); |
| 26 | + |
| 27 | + private HybridResourceCache CreateSut(ResourceCacheRedisSettings redisSettings) |
| 28 | + { |
| 29 | + var settings = new ResourceCacheSettings { Redis = redisSettings }; |
| 30 | + return new HybridResourceCache( |
| 31 | + _redisCache.Object, |
| 32 | + _absCache.Object, |
| 33 | + _multiplexer.Object, |
| 34 | + Options.Create(settings), |
| 35 | + _logger.Object); |
| 36 | + } |
| 37 | + |
| 38 | + /// <summary>Configures a connected Redis server whose INFO memory section returns the given used_memory.</summary> |
| 39 | + private void SetupRedisUsedMemory(long usedMemory) |
| 40 | + { |
| 41 | + SetupRedisInfo(new[] { new KeyValuePair<string, string>("used_memory", usedMemory.ToString()) }); |
| 42 | + } |
| 43 | + |
| 44 | + private void SetupRedisInfo(IEnumerable<KeyValuePair<string, string>> memoryPairs) |
| 45 | + { |
| 46 | + var grouping = memoryPairs.GroupBy(_ => "memory").First(); |
| 47 | + var server = new Mock<IServer>(); |
| 48 | + server.SetupGet(s => s.IsConnected).Returns(true); |
| 49 | + server.Setup(s => s.Info(It.IsAny<RedisValue>(), It.IsAny<CommandFlags>())) |
| 50 | + .Returns(new[] { grouping }); |
| 51 | + _multiplexer.Setup(m => m.GetServers()).Returns(new[] { server.Object }); |
| 52 | + } |
| 53 | + |
| 54 | + private void SetupNoConnectedServer() |
| 55 | + { |
| 56 | + _multiplexer.Setup(m => m.GetServers()).Returns(Array.Empty<IServer>()); |
| 57 | + } |
| 58 | + |
| 59 | + private void Write(HybridResourceCache sut, string correlationId) |
| 60 | + { |
| 61 | + sut.UpdateCorrelationCache(correlationId, new List<DomainResource>(), ResourceType.Patient); |
| 62 | + } |
| 63 | + |
| 64 | + private void VerifyWroteTo(Mock<IResourceCache> expected, Mock<IResourceCache> notExpected) |
| 65 | + { |
| 66 | + expected.Verify(c => c.UpdateCorrelationCache(It.IsAny<string>(), It.IsAny<List<DomainResource>>(), It.IsAny<ResourceType>()), Times.Once); |
| 67 | + notExpected.Verify(c => c.UpdateCorrelationCache(It.IsAny<string>(), It.IsAny<List<DomainResource>>(), It.IsAny<ResourceType>()), Times.Never); |
| 68 | + } |
| 69 | + |
| 70 | + private void VerifyWarningLogged(Times times) |
| 71 | + { |
| 72 | + _logger.Verify( |
| 73 | + log => log.Log( |
| 74 | + LogLevel.Warning, |
| 75 | + It.IsAny<EventId>(), |
| 76 | + It.IsAny<It.IsAnyType>(), |
| 77 | + It.IsAny<Exception>(), |
| 78 | + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), |
| 79 | + times); |
| 80 | + } |
| 81 | + |
| 82 | + [Fact] |
| 83 | + public void UsedMemory_below_threshold_of_configured_max_uses_Redis() |
| 84 | + { |
| 85 | + // 100 MB used of 1000 MB max = 10%, threshold 80% => Redis |
| 86 | + SetupRedisUsedMemory(100L * 1024 * 1024); |
| 87 | + var sut = CreateSut(new ResourceCacheRedisSettings { MaxMemoryBytes = 1000L * 1024 * 1024, MemoryThresholdPercent = 80.0 }); |
| 88 | + |
| 89 | + Write(sut, "corr-below"); |
| 90 | + |
| 91 | + VerifyWroteTo(_redisCache, _absCache); |
| 92 | + } |
| 93 | + |
| 94 | + [Fact] |
| 95 | + public void UsedMemory_at_or_above_threshold_of_configured_max_uses_ABS() |
| 96 | + { |
| 97 | + // 900 MB used of 1000 MB max = 90%, threshold 80% => ABS |
| 98 | + SetupRedisUsedMemory(900L * 1024 * 1024); |
| 99 | + var sut = CreateSut(new ResourceCacheRedisSettings { MaxMemoryBytes = 1000L * 1024 * 1024, MemoryThresholdPercent = 80.0 }); |
| 100 | + |
| 101 | + Write(sut, "corr-above"); |
| 102 | + |
| 103 | + VerifyWroteTo(_absCache, _redisCache); |
| 104 | + } |
| 105 | + |
| 106 | + [Theory] |
| 107 | + [InlineData(null)] |
| 108 | + [InlineData(0L)] |
| 109 | + [InlineData(-1L)] |
| 110 | + public void MaxMemoryBytes_missing_or_invalid_uses_Redis_and_logs_warning(long? maxMemoryBytes) |
| 111 | + { |
| 112 | + SetupRedisUsedMemory(900L * 1024 * 1024); // would be ABS if a valid max were configured |
| 113 | + var sut = CreateSut(new ResourceCacheRedisSettings { MaxMemoryBytes = maxMemoryBytes, MemoryThresholdPercent = 80.0 }); |
| 114 | + |
| 115 | + Write(sut, "corr-nomax"); |
| 116 | + |
| 117 | + VerifyWroteTo(_redisCache, _absCache); |
| 118 | + VerifyWarningLogged(Times.Once()); |
| 119 | + } |
| 120 | + |
| 121 | + [Fact] |
| 122 | + public void UsedMemory_missing_from_info_uses_Redis() |
| 123 | + { |
| 124 | + SetupRedisInfo(new[] { new KeyValuePair<string, string>("some_other_stat", "123") }); |
| 125 | + var sut = CreateSut(new ResourceCacheRedisSettings { MaxMemoryBytes = 1000L * 1024 * 1024, MemoryThresholdPercent = 80.0 }); |
| 126 | + |
| 127 | + Write(sut, "corr-nousedmem"); |
| 128 | + |
| 129 | + VerifyWroteTo(_redisCache, _absCache); |
| 130 | + } |
| 131 | + |
| 132 | + [Fact] |
| 133 | + public void No_connected_server_uses_ABS() |
| 134 | + { |
| 135 | + SetupNoConnectedServer(); |
| 136 | + var sut = CreateSut(new ResourceCacheRedisSettings { MaxMemoryBytes = 1000L * 1024 * 1024, MemoryThresholdPercent = 80.0 }); |
| 137 | + |
| 138 | + Write(sut, "corr-noserver"); |
| 139 | + |
| 140 | + VerifyWroteTo(_absCache, _redisCache); |
| 141 | + } |
| 142 | + |
| 143 | + [Fact] |
| 144 | + public void Exception_reading_memory_uses_ABS() |
| 145 | + { |
| 146 | + _multiplexer.Setup(m => m.GetServers()).Throws(new RedisException("boom")); |
| 147 | + var sut = CreateSut(new ResourceCacheRedisSettings { MaxMemoryBytes = 1000L * 1024 * 1024, MemoryThresholdPercent = 80.0 }); |
| 148 | + |
| 149 | + Write(sut, "corr-throws"); |
| 150 | + |
| 151 | + VerifyWroteTo(_absCache, _redisCache); |
| 152 | + } |
| 153 | + |
| 154 | + [Fact] |
| 155 | + public void Decision_is_memoized_per_correlationId() |
| 156 | + { |
| 157 | + SetupRedisUsedMemory(100L * 1024 * 1024); |
| 158 | + var sut = CreateSut(new ResourceCacheRedisSettings { MaxMemoryBytes = 1000L * 1024 * 1024, MemoryThresholdPercent = 80.0 }); |
| 159 | + |
| 160 | + Write(sut, "corr-memo"); |
| 161 | + Write(sut, "corr-memo"); |
| 162 | + |
| 163 | + // The memory pressure check (GetServers) should only happen once for the same correlationId. |
| 164 | + _multiplexer.Verify(m => m.GetServers(), Times.Once); |
| 165 | + _redisCache.Verify(c => c.UpdateCorrelationCache(It.IsAny<string>(), It.IsAny<List<DomainResource>>(), It.IsAny<ResourceType>()), Times.Exactly(2)); |
| 166 | + } |
| 167 | +} |
0 commit comments