mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
feat(cache): add IMemoryCache tier in front of Redis and cover invalidation paths
This commit is contained in:
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,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>
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user