Skip to content

Commit 38faa41

Browse files
LEGLINK-770: Supply Redis max-memory via config for Hybrid cache fallback (#1764)
* LEGLINK-770: Supply Redis max-memory via config for Hybrid cache fallback Azure Managed Redis does not return `maxmemory` from the INFO command, so HybridResourceCache always assumed Redis was unconstrained and never cut over to ABS under memory pressure. Per the LEGLINK-603 investigation (Option C), the max-memory limit is now supplied through configuration. - Add ResourceCache:Redis:MaxMemoryBytes to ResourceCacheRedisSettings - SelectCacheType now uses the configured max as the utilization denominator (still reads used_memory from INFO); when the value is missing or <= 0 it logs a warning and defaults to Redis - Document the key in app-config.yaml (both DataAcquisition sections) and default it to 268435456 (256 MB) in the DataAcquisition, AcquisitionWorker and Normalization appsettings; Azure App Config overrides per environment - docker-compose: cap local Redis at 256mb (allkeys-lru) and pass a matching MaxMemoryBytes to the cache-consuming services - Add HybridResourceCacheTests covering the selection branches * LEGLINK-770: Document Normalization ResourceCache config keys Addresses a CodeRabbit review finding on PR #1764: the Normalization service consumes the Hybrid resource cache but app-config.yaml listed its section as empty (Normalization: []), leaving all ResourceCache keys — including the new ResourceCache:Redis:MaxMemoryBytes — undocumented. Document Normalization's full ResourceCache block (mirroring the DataAcquisition and DataAcquisitionWorker sections), including the Azure Redis 1 GB capacity guidance for MaxMemoryBytes.
1 parent f05fed4 commit 38faa41

8 files changed

Lines changed: 232 additions & 15 deletions

File tree

DotNet/DataAcquisition.AcquisitionWorker/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"Redis": {
2727
"ConnectionString": "",
2828
"Password": "",
29-
"MemoryThresholdPercent": 80.0
29+
"MemoryThresholdPercent": 80.0,
30+
"MaxMemoryBytes": 268435456
3031
},
3132
"BlobStorage": {
3233
"ConnectionString": "",

DotNet/DataAcquisition/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"Redis": {
4242
"ConnectionString": "",
4343
"Password": "",
44-
"MemoryThresholdPercent": 80.0
44+
"MemoryThresholdPercent": 80.0,
45+
"MaxMemoryBytes": 268435456
4546
},
4647
"BlobStorage": {
4748
"ConnectionString": "",

DotNet/Normalization/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@
9393
"Redis": {
9494
"ConnectionString": "",
9595
"Password": "",
96-
"MemoryThresholdPercent": 80.0
96+
"MemoryThresholdPercent": 80.0,
97+
"MaxMemoryBytes": 268435456
9798
},
9899
"BlobStorage": {
99100
"ConnectionString": "",
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
}

DotNet/Shared/Application/Models/Configs/ResourceCacheSettings.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,20 @@ public class ResourceCacheRedisSettings
2525
public string? ConnectionString { get; set; }
2626
public string? Password { get; set; }
2727
/// <summary>
28-
/// The percentage of maxmemory at which the cache will fall back to ABS.
29-
/// Defaults to 80. When Redis maxmemory is 0 (unlimited), Redis is always used.
28+
/// The percentage of the configured Redis max-memory (<see cref="MaxMemoryBytes"/>) at
29+
/// which Hybrid caching falls back to ABS. Defaults to 80. When
30+
/// <see cref="MaxMemoryBytes"/> is unknown (null or &lt;= 0) Redis is always used.
3031
/// </summary>
3132
public double MemoryThresholdPercent { get; set; } = 80.0;
33+
34+
/// <summary>
35+
/// The Redis max-memory limit in bytes, supplied via configuration because Azure
36+
/// Managed Redis does not return <c>maxmemory</c> via the INFO command. Used as the
37+
/// denominator when computing memory utilization for Hybrid cache fallback (the
38+
/// numerator, <c>used_memory</c>, is still read from INFO). When null or &lt;= 0 the
39+
/// limit is treated as unknown and Redis is always used (a warning is logged).
40+
/// NOTE: this must be updated manually if Redis capacity is scaled.
41+
/// </summary>
42+
public long? MaxMemoryBytes { get; set; }
3243
}
3344
}

DotNet/Shared/Application/Services/ResourceCache/HybridResourceCache.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,19 @@ private ResourceCacheType SelectCacheType()
137137
return ResourceCacheType.Redis;
138138
}
139139

140-
if (!infoDict.TryGetValue("maxmemory", out var maxMemoryStr) ||
141-
!long.TryParse(maxMemoryStr, out var maxMemory) ||
142-
maxMemory == 0)
140+
// Azure Managed Redis does not return `maxmemory` via INFO, so the limit is
141+
// supplied through configuration instead. Continue reading `used_memory` above.
142+
var maxMemoryBytes = _settings.Redis.MaxMemoryBytes;
143+
if (maxMemoryBytes is null or <= 0)
143144
{
144-
// No memory limit configured — Redis is unconstrained, always use it.
145-
_logger.LogDebug("Redis maxmemory is not configured or unlimited; defaulting to Redis resource cache.");
145+
_logger.LogWarning(
146+
"ResourceCache:Redis:MaxMemoryBytes is not configured or invalid ({MaxMemoryBytes}); " +
147+
"cannot evaluate Redis memory pressure. Defaulting to Redis resource cache.",
148+
maxMemoryBytes);
146149
return ResourceCacheType.Redis;
147150
}
148151

149-
double usagePercent = (double)usedMemory / maxMemory * 100.0;
152+
double usagePercent = (double)usedMemory / maxMemoryBytes.Value * 100.0;
150153

151154
if (usagePercent >= _settings.Redis.MemoryThresholdPercent)
152155
{

app-config.yaml

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ services:
217217
description: "Optional Redis password for the resource cache. Defaults to empty."
218218
required: false
219219
- key: "ResourceCache:Redis:MemoryThresholdPercent"
220-
description: "Optional Redis maxmemory percentage at which Hybrid caching falls back to blob storage. Defaults to 80.0."
220+
description: "Optional percentage of ResourceCache:Redis:MaxMemoryBytes at which Hybrid caching falls back to blob storage. Defaults to 80.0."
221+
required: false
222+
- key: "ResourceCache:Redis:MaxMemoryBytes"
223+
description: "The Redis max-memory limit in bytes, used as the denominator for Hybrid cache memory-pressure fallback. Supplied via configuration because Azure Managed Redis does not return maxmemory via INFO. Recommended for Hybrid; when unset or <= 0 Redis is always used (a warning is logged). All LCG Redis instances are currently 1 GB (1073741824); this must be updated if Redis capacity is scaled. Defaults to 268435456 (256 MB) to match local Docker Redis."
221224
required: false
222225
- key: "ResourceCache:BlobStorage:ConnectionString"
223226
description: "The Azure Blob Storage connection string used by the resource cache. Required when ResourceCache:CacheImplementation is Hybrid or ABS."
@@ -248,7 +251,10 @@ services:
248251
description: "Optional Redis password for the resource cache. Defaults to empty."
249252
required: false
250253
- key: "ResourceCache:Redis:MemoryThresholdPercent"
251-
description: "Optional Redis maxmemory percentage at which Hybrid caching falls back to blob storage. Defaults to 80.0."
254+
description: "Optional percentage of ResourceCache:Redis:MaxMemoryBytes at which Hybrid caching falls back to blob storage. Defaults to 80.0."
255+
required: false
256+
- key: "ResourceCache:Redis:MaxMemoryBytes"
257+
description: "The Redis max-memory limit in bytes, used as the denominator for Hybrid cache memory-pressure fallback. Supplied via configuration because Azure Managed Redis does not return maxmemory via INFO. Recommended for Hybrid; when unset or <= 0 Redis is always used (a warning is logged). All LCG Redis instances are currently 1 GB (1073741824); this must be updated if Redis capacity is scaled. Defaults to 268435456 (256 MB) to match local Docker Redis."
252258
required: false
253259
- key: "ResourceCache:BlobStorage:ConnectionString"
254260
description: "The Azure Blob Storage connection string used by the resource cache. Required when ResourceCache:CacheImplementation is Hybrid or ABS."
@@ -288,7 +294,31 @@ services:
288294
- key: "resource-cache.blob-storage.blob-root"
289295
description: "Root path/prefix within the blob container under which cached resources are stored for the ABS resource cache. Required only when the ABS resource-cache backend is used."
290296
required: false
291-
Normalization: []
297+
Normalization:
298+
- key: "ResourceCache:CacheImplementation"
299+
description: "The resource cache implementation to register. Defaults to Hybrid; supported values are Hybrid, Redis, and ABS."
300+
required: false
301+
- key: "ResourceCache:Redis:ConnectionString"
302+
description: "The Redis endpoint used by the resource cache. Required when ResourceCache:CacheImplementation is Hybrid or Redis."
303+
required: true
304+
- key: "ResourceCache:Redis:Password"
305+
description: "Optional Redis password for the resource cache. Defaults to empty."
306+
required: false
307+
- key: "ResourceCache:Redis:MemoryThresholdPercent"
308+
description: "Optional percentage of ResourceCache:Redis:MaxMemoryBytes at which Hybrid caching falls back to blob storage. Defaults to 80.0."
309+
required: false
310+
- key: "ResourceCache:Redis:MaxMemoryBytes"
311+
description: "The Redis max-memory limit in bytes, used as the denominator for Hybrid cache memory-pressure fallback. Supplied via configuration because Azure Managed Redis does not return maxmemory via INFO. Recommended for Hybrid; when unset or <= 0 Redis is always used (a warning is logged). All LCG Redis instances are currently 1 GB (1073741824); this must be updated if Redis capacity is scaled. Defaults to 268435456 (256 MB) to match local Docker Redis."
312+
required: false
313+
- key: "ResourceCache:BlobStorage:ConnectionString"
314+
description: "The Azure Blob Storage connection string used by the resource cache. Required when ResourceCache:CacheImplementation is Hybrid or ABS."
315+
required: true
316+
- key: "ResourceCache:BlobStorage:BlobContainerName"
317+
description: "Optional blob container name used by the resource cache. Defaults to empty."
318+
required: false
319+
- key: "ResourceCache:BlobStorage:BlobRoot"
320+
description: "Optional blob name prefix used by the resource cache. Defaults to empty."
321+
required: false
292322
Notification:
293323
- key: "SmtpConnection:Host"
294324
description: "The SMTP server hostname for sending notifications."

docker-compose.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ services:
9696
ports:
9797
- '6379:6379'
9898
container_name: redis_cache
99-
command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASS}
99+
command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASS} --maxmemory 256mb --maxmemory-policy allkeys-lru
100100
volumes:
101101
- redis_cache:/data
102102
networks:
@@ -600,6 +600,7 @@ services:
600600
ResourceCache__CacheImplementation: Hybrid
601601
ResourceCache__Redis__ConnectionString: redis_cache:6379
602602
ResourceCache__Redis__Password: ${REDIS_PASS}
603+
ResourceCache__Redis__MaxMemoryBytes: 268435456
603604
ResourceCache__BlobStorage__ConnectionString: ${AZURITE_CONNECTION_STRING}
604605
ResourceCache__BlobStorage__BlobContainerName: ${INTERNAL_BLOB_CONTAINER_NAME}
605606
ResourceCache__BlobStorage__BlobRoot: resource-cache
@@ -643,6 +644,7 @@ services:
643644
ResourceCache__CacheImplementation: Hybrid
644645
ResourceCache__Redis__ConnectionString: redis_cache:6379
645646
ResourceCache__Redis__Password: ${REDIS_PASS}
647+
ResourceCache__Redis__MaxMemoryBytes: 268435456
646648
ResourceCache__BlobStorage__ConnectionString: ${AZURITE_CONNECTION_STRING}
647649
ResourceCache__BlobStorage__BlobContainerName: ${INTERNAL_BLOB_CONTAINER_NAME}
648650
ResourceCache__BlobStorage__BlobRoot: resource-cache
@@ -776,6 +778,7 @@ services:
776778
ConnectionStrings__DatabaseConnection: Server=tcp:mssql,1433;Initial Catalog=link-normalization;Persist Security Info=False;User ID=sa;Password=${LINK_DB_PASS};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=True;Connection Timeout=30;
777779
ResourceCache__Redis__ConnectionString: redis_cache:6379
778780
ResourceCache__Redis__Password: ${REDIS_PASS}
781+
ResourceCache__Redis__MaxMemoryBytes: 268435456
779782
ResourceCache__BlobStorage__ConnectionString: ${AZURITE_CONNECTION_STRING}
780783
ResourceCache__BlobStorage__BlobContainerName: ${INTERNAL_BLOB_CONTAINER_NAME}
781784
ResourceCache__BlobStorage__BlobRoot: resource-cache

0 commit comments

Comments
 (0)