feat(cache): add IMemoryCache tier in front of Redis and cover invalidation paths

This commit is contained in:
2026-04-05 12:12:40 -04:00
parent e34c4bd125
commit 8be544bdfc
10 changed files with 294 additions and 35 deletions
@@ -122,7 +122,8 @@ public class ConfigControllerAuthorizationTests
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object);
redisLogger.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()),
+4 -3
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
@@ -31,7 +32,7 @@ public class JellyfinProxyServiceTests
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_settings = new JellyfinSettings
{
@@ -52,7 +53,7 @@ public class JellyfinProxyServiceTests
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
var serviceProvider = serviceCollection.BuildServiceProvider();
CacheExtensions.InitializeCacheSettings(serviceProvider);
allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
_service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
@@ -629,7 +630,7 @@ public class JellyfinProxyServiceTests
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
@@ -182,7 +182,8 @@ public class JellyfinSessionManagerTests
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
NullLogger<RedisCacheService>.Instance);
NullLogger<RedisCacheService>.Instance,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
return new JellyfinProxyService(
httpClientFactory,
+2 -1
View File
@@ -1,5 +1,6 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using allstarr.Services.Lyrics;
using allstarr.Services.Common;
@@ -23,7 +24,7 @@ public class LrclibServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_httpClient = new HttpClient
{
+109 -16
View File
@@ -1,6 +1,7 @@
using Xunit;
using Moq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.Common;
@@ -23,11 +24,19 @@ public class RedisCacheServiceTests
});
}
private RedisCacheService CreateService(IMemoryCache? memoryCache = null, IOptions<RedisSettings>? settings = null)
{
return new RedisCacheService(
settings ?? _settings,
_mockLogger.Object,
memoryCache ?? new MemoryCache(new MemoryCacheOptions()));
}
[Fact]
public void Constructor_InitializesWithSettings()
{
// Act
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Assert
Assert.NotNull(service);
@@ -45,7 +54,7 @@ public class RedisCacheServiceTests
});
// Act - Constructor will try to connect but should handle failure gracefully
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
var service = CreateService(settings: enabledSettings);
// Assert - Service should be created even if connection fails
Assert.NotNull(service);
@@ -55,7 +64,7 @@ public class RedisCacheServiceTests
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetStringAsync("test:key");
@@ -68,7 +77,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetAsync<TestObject>("test:key");
@@ -81,7 +90,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.SetStringAsync("test:key", "test value");
@@ -94,7 +103,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" };
// Act
@@ -108,7 +117,7 @@ public class RedisCacheServiceTests
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.DeleteAsync("test:key");
@@ -121,7 +130,7 @@ public class RedisCacheServiceTests
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.ExistsAsync("test:key");
@@ -134,7 +143,7 @@ public class RedisCacheServiceTests
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.DeleteByPatternAsync("test:*");
@@ -147,7 +156,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var expiry = TimeSpan.FromHours(1);
// Act
@@ -161,7 +170,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var testObj = new TestObject { Id = 1, Name = "Test" };
var expiry = TimeSpan.FromDays(30);
@@ -176,14 +185,14 @@ public class RedisCacheServiceTests
public void IsEnabled_ReflectsSettings()
{
// Arrange
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
var disabledService = CreateService();
var enabledSettings = Options.Create(new RedisSettings
{
Enabled = true,
ConnectionString = "localhost:6379"
});
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
var enabledService = CreateService(settings: enabledSettings);
// Assert
Assert.False(disabledService.IsEnabled);
@@ -194,7 +203,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_DeserializesComplexObjects()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
// Act
var result = await service.GetAsync<ComplexTestObject>("test:complex");
@@ -207,7 +216,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_SerializesComplexObjects()
{
// Arrange
var service = new RedisCacheService(_settings, _mockLogger.Object);
var service = CreateService();
var complexObj = new ComplexTestObject
{
Id = 1,
@@ -238,12 +247,96 @@ public class RedisCacheServiceTests
});
// Act
var service = new RedisCacheService(customSettings, _mockLogger.Object);
var service = CreateService(settings: customSettings);
// Assert
Assert.NotNull(service);
}
[Fact]
public async Task SetStringAsync_WhenDisabled_CachesValueInMemory()
{
var service = CreateService();
var setResult = await service.SetStringAsync("test:key", "test value");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(setResult);
Assert.Equal("test value", cachedValue);
}
[Fact]
public async Task SetAsync_WhenDisabled_CachesSerializedObjectInMemory()
{
var service = CreateService();
var expected = new TestObject { Id = 42, Name = "Tiered" };
var setResult = await service.SetAsync("test:object", expected);
var cachedValue = await service.GetAsync<TestObject>("test:object");
Assert.False(setResult);
Assert.NotNull(cachedValue);
Assert.Equal(expected.Id, cachedValue.Id);
Assert.Equal(expected.Name, cachedValue.Name);
}
[Fact]
public async Task ExistsAsync_WhenValueOnlyExistsInMemory_ReturnsTrue()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var exists = await service.ExistsAsync("test:key");
Assert.True(exists);
}
[Fact]
public async Task DeleteAsync_WhenValueOnlyExistsInMemory_EvictsEntry()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var deleted = await service.DeleteAsync("test:key");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(deleted);
Assert.Null(cachedValue);
}
[Fact]
public async Task DeleteByPatternAsync_WhenValuesOnlyExistInMemory_RemovesMatchingEntries()
{
var service = CreateService();
await service.SetStringAsync("search:one", "1");
await service.SetStringAsync("search:two", "2");
await service.SetStringAsync("other:one", "3");
var deletedCount = await service.DeleteByPatternAsync("search:*");
Assert.Equal(2, deletedCount);
Assert.Null(await service.GetStringAsync("search:one"));
Assert.Null(await service.GetStringAsync("search:two"));
Assert.Equal("3", await service.GetStringAsync("other:one"));
}
[Fact]
public async Task SetStringAsync_ImageKeysDoNotUseMemoryCache()
{
var service = CreateService();
await service.SetStringAsync("image:test:key", "binary-ish");
var cachedValue = await service.GetStringAsync("image:test:key");
var exists = await service.ExistsAsync("image:test:key");
Assert.Null(cachedValue);
Assert.False(exists);
}
private class TestObject
{
public int Id { get; set; }
+2 -1
View File
@@ -2,6 +2,7 @@ using Xunit;
using Moq;
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.Spotify;
@@ -30,7 +31,7 @@ public class SpotifyMappingServiceTests
ConnectionString = "localhost:6379"
});
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object);
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_service = new SpotifyMappingService(_cache, _mockLogger.Object);
}
@@ -85,7 +85,8 @@ public class SquidWTFDownloadServiceTests : IDisposable
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
_redisLoggerMock.Object);
_redisLoggerMock.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
@@ -1,5 +1,6 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.SquidWTF;
@@ -42,7 +43,10 @@ public class SquidWTFMetadataServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_mockCache = new Mock<RedisCacheService>(
mockRedisSettings,
mockRedisLogger.Object,
new MemoryCache(new MemoryCacheOptions()));
_apiUrls = new List<string>
{
+1
View File
@@ -528,6 +528,7 @@ else
// Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<FavoritesMigrationService>();
builder.Services.AddSingleton<OdesliService>();
+165 -10
View File
@@ -1,29 +1,49 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Serialization;
using StackExchange.Redis;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common;
/// <summary>
/// Redis caching service for metadata and images.
/// Tiered caching service: L1 in-memory (IMemoryCache, ~30s TTL) backed by
/// L2 Redis for persistence. The memory tier eliminates Redis network round-trips
/// for repeated reads within a short window (playlist scrolling, search-as-you-type).
/// </summary>
public class RedisCacheService
{
/// <summary>
/// Default L1 memory cache duration. Kept short to avoid serving stale data,
/// but long enough to absorb bursts of repeated reads.
/// </summary>
private static readonly TimeSpan DefaultMemoryTtl = TimeSpan.FromSeconds(30);
/// <summary>
/// Key prefixes that should NOT be cached in memory (e.g., large binary blobs).
/// </summary>
private static readonly string[] MemoryExcludedPrefixes = ["image:"];
private readonly RedisSettings _settings;
private readonly ILogger<RedisCacheService> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _redis;
private IDatabase? _db;
private readonly object _lock = new();
public RedisCacheService(
IOptions<RedisSettings> settings,
ILogger<RedisCacheService> logger)
ILogger<RedisCacheService> logger,
IMemoryCache memoryCache)
{
_settings = settings.Value;
_logger = logger;
_memoryCache = memoryCache;
if (_settings.Enabled)
{
@@ -49,24 +69,141 @@ public class RedisCacheService
public bool IsEnabled => _settings.Enabled && _db != null;
/// <summary>
/// Checks whether a key should be cached in the L1 memory tier.
/// Large binary data (images) is excluded to avoid memory pressure.
/// </summary>
private static bool ShouldUseMemoryCache(string key)
{
foreach (var prefix in MemoryExcludedPrefixes)
{
if (key.StartsWith(prefix, StringComparison.Ordinal))
return false;
}
return true;
}
/// <summary>
/// Computes the L1 TTL for a key mirrored from Redis.
/// Returns null for already-expired entries, which skips L1 caching entirely.
/// </summary>
private static TimeSpan? GetMemoryTtl(TimeSpan? redisExpiry)
{
if (redisExpiry == null)
return DefaultMemoryTtl;
if (redisExpiry.Value <= TimeSpan.Zero)
return null;
return redisExpiry.Value < DefaultMemoryTtl ? redisExpiry.Value : DefaultMemoryTtl;
}
private bool TryGetMemoryValue(string key, out string? value)
{
if (!ShouldUseMemoryCache(key))
{
value = null;
return false;
}
return _memoryCache.TryGetValue(key, out value);
}
private void SetMemoryValue(string key, string value, TimeSpan? expiry)
{
if (!ShouldUseMemoryCache(key))
return;
var memoryTtl = GetMemoryTtl(expiry);
if (memoryTtl == null)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
return;
}
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = memoryTtl
};
options.RegisterPostEvictionCallback(
static (cacheKey, _, _, state) =>
{
if (cacheKey is string stringKey && state is ConcurrentDictionary<string, byte> memoryKeys)
{
memoryKeys.TryRemove(stringKey, out _);
}
},
_memoryKeys);
_memoryCache.Set(key, value, options);
_memoryKeys[key] = 0;
}
private int RemoveMemoryKeysByPattern(string pattern)
{
if (_memoryKeys.IsEmpty)
return 0;
if (!pattern.Contains('*') && !pattern.Contains('?'))
{
var removed = _memoryKeys.TryRemove(pattern, out _);
_memoryCache.Remove(pattern);
return removed ? 1 : 0;
}
var regex = new Regex(
"^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$",
RegexOptions.CultureInvariant);
var keysToRemove = _memoryKeys.Keys.Where(key => regex.IsMatch(key)).ToArray();
foreach (var key in keysToRemove)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
}
return keysToRemove.Length;
}
/// <summary>
/// Gets a cached value as a string.
/// Checks L1 memory cache first, falls back to L2 Redis.
/// </summary>
public async Task<string?> GetStringAsync(string key)
{
// L1: Try in-memory cache first (sub-microsecond)
if (TryGetMemoryValue(key, out var memoryValue))
{
_logger.LogDebug("L1 memory cache HIT: {Key}", key);
return memoryValue;
}
if (!IsEnabled) return null;
try
{
// L2: Fall back to Redis
var value = await _db!.StringGetAsync(key);
if (value.HasValue)
{
_logger.LogDebug("Redis cache HIT: {Key}", key);
_logger.LogDebug("L2 Redis cache HIT: {Key}", key);
// Promote to L1 for subsequent reads
if (ShouldUseMemoryCache(key))
{
var stringValue = (string?)value;
if (stringValue != null)
{
var redisExpiry = await _db.KeyTimeToLiveAsync(key);
SetMemoryValue(key, stringValue, redisExpiry);
}
}
}
else
{
_logger.LogDebug("Redis cache MISS: {Key}", key);
_logger.LogDebug("Cache MISS: {Key}", key);
}
return value;
}
@@ -104,9 +241,13 @@ public class RedisCacheService
/// <summary>
/// Sets a cached value with TTL.
/// Writes to both L1 memory cache and L2 Redis.
/// </summary>
public async Task<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
{
// Always update L1 (even if Redis is down — provides degraded caching)
SetMemoryValue(key, value, expiry);
if (!IsEnabled) return false;
try
@@ -243,10 +384,14 @@ public class RedisCacheService
}
/// <summary>
/// Deletes a cached value.
/// Deletes a cached value from both L1 memory and L2 Redis.
/// </summary>
public async Task<bool> DeleteAsync(string key)
{
// Always evict from L1
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
if (!IsEnabled) return false;
try
@@ -265,6 +410,11 @@ public class RedisCacheService
/// </summary>
public async Task<bool> ExistsAsync(string key)
{
if (ShouldUseMemoryCache(key) && _memoryCache.TryGetValue(key, out _))
{
return true;
}
if (!IsEnabled) return false;
try
@@ -303,7 +453,8 @@ public class RedisCacheService
/// </summary>
public async Task<int> DeleteByPatternAsync(string pattern)
{
if (!IsEnabled) return 0;
var memoryDeleted = RemoveMemoryKeysByPattern(pattern);
if (!IsEnabled) return memoryDeleted;
try
{
@@ -312,18 +463,22 @@ public class RedisCacheService
if (keys.Length == 0)
{
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
return 0;
_logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
return memoryDeleted;
}
var deleted = await _db!.KeyDeleteAsync(keys);
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
_logger.LogDebug(
"Deleted {RedisCount} Redis keys and {MemoryCount} memory keys matching pattern: {Pattern}",
deleted,
memoryDeleted,
pattern);
return (int)deleted;
}
catch (Exception ex)
{
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
return 0;
return memoryDeleted;
}
}
}