refactor(cache): consolidate versioned key namespaces

This commit is contained in:
joshpatra committed 2026-07-27 07:46:37 -04:00
1 parent 98073e031b
commit cfdcf4cd30
17 files changed
+389 -293

No files matched your search

@@ -29,8 +29,8 @@ public sealed class ApplicationCacheContractTests
nameof(IApplicationCache.SetAsync),
nameof(IApplicationCache.DeleteAsync),
nameof(IApplicationCache.ExistsAsync),
nameof(IApplicationCache.GetKeysByPattern),
nameof(IApplicationCache.DeleteByPatternAsync),
nameof(IApplicationCache.PurgeAllAsync),
},
methodNames);
}
@@ -33,39 +33,39 @@ public sealed class BoundedHotApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task SuccessfulWrite_IsServedAfterDatabaseRowIsRemoved()
{
Assert.True(await _cache.SetStringAsync("odesli:hot:track:1", "cached"));
Assert.True(await _cache.SetStringAsync("odesli:translate:v2:hot-1:spotify", "cached"));
await using (var context = await _factory.CreateDbContextAsync())
{
await context.ApplicationCacheEntries.ExecuteDeleteAsync();
}
Assert.Equal("cached", await _cache.GetStringAsync("odesli:hot:track:1"));
Assert.Equal("cached", await _cache.GetStringAsync("odesli:translate:v2:hot-1:spotify"));
}
[Fact]
public async Task Delete_RemovesHotAndDatabaseCopies()
{
await _cache.SetStringAsync("odesli:hot:track:2", "cached");
await _cache.SetStringAsync("odesli:translate:v2:hot-2:spotify", "cached");
Assert.True(await _cache.DeleteAsync("odesli:hot:track:2"));
Assert.Null(await _cache.GetStringAsync("odesli:hot:track:2"));
Assert.True(await _cache.DeleteAsync("odesli:translate:v2:hot-2:spotify"));
Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:hot-2:spotify"));
}
[Fact]
public async Task PatternDelete_ClearsHotTierBeforeDeletingDatabaseRows()
{
await _cache.SetStringAsync("odesli:playlist:one", "one");
await _cache.SetStringAsync("odesli:track:one", "track");
await _cache.SetStringAsync("odesli:translate:v2:playlist-one:spotify", "one");
await _cache.SetStringAsync("odesli:translate:v2:track-one:spotify", "track");
Assert.Equal(1, await _cache.DeleteByPatternAsync("odesli:playlist:*"));
Assert.Equal(1, await _cache.DeleteByPatternAsync("odesli:translate:v2:playlist-*:spotify"));
await using (var context = await _factory.CreateDbContextAsync())
{
await context.ApplicationCacheEntries
.Where(item => item.Key == "odesli:track:one")
.Where(item => item.Key == "odesli:translate:v2:track-one:spotify")
.ExecuteDeleteAsync();
}
Assert.Null(await _cache.GetStringAsync("odesli:track:one"));
Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:track-one:spotify"));
}
[Fact]
@@ -73,8 +73,8 @@ public sealed class BoundedHotApplicationCacheTests : IAsyncLifetime
{
var value = new string('x', BoundedHotApplicationCache.MaximumEntryBytes + 1);
Assert.True(await _cache.SetStringAsync("odesli:large:metadata", value));
Assert.Equal(value, await _cache.GetStringAsync("odesli:large:metadata"));
Assert.True(await _cache.SetStringAsync("odesli:translate:v2:large:spotify", value));
Assert.Equal(value, await _cache.GetStringAsync("odesli:translate:v2:large:spotify"));
}
public async Task DisposeAsync()
+15 -15
View File
@@ -50,7 +50,7 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime
[Fact]
public async Task CategoryPolicySuppliesDefaultExpiry()
{
const string key = "playlist:discovery:v1:fixture";
const string key = "playlist:discovery:v2:global:shared:00000000000000000000000000000000:1:fixture:digest";
Assert.True(await _cache.SetStringAsync(key, "{}"));
await using var context = new AllstarrDbContext(_database.Options);
@@ -62,8 +62,8 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime
[Fact]
public async Task Snapshot_ReportsEveryTierAndScopedPurgesStayIsolated()
{
Assert.True(await _cache.SetStringAsync("odesli:track:1", "metadata"));
Assert.True(await _cache.SetStringAsync("image:track:1", "media"));
Assert.True(await _cache.SetStringAsync("odesli:translate:v2:track-1:spotify", "metadata"));
Assert.True(await _cache.SetStringAsync("artwork:payload:v1:track-1", "media"));
var snapshot = await _cache.GetDiagnosticsAsync();
Assert.Equal(1, snapshot.Database.EntryCount);
@@ -98,19 +98,19 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime
Assert.Equal(5, artworkCategory.PayloadBytes);
Assert.Equal(1, await _cache.PurgeMediaAsync());
Assert.Equal("metadata", await _cache.GetStringAsync("odesli:track:1"));
Assert.Null(await _cache.GetStringAsync("image:track:1"));
Assert.Equal("metadata", await _cache.GetStringAsync("odesli:translate:v2:track-1:spotify"));
Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:track-1"));
snapshot = await _cache.GetDiagnosticsAsync();
Assert.Equal(1, snapshot.Hot.Hits);
Assert.Equal(1, snapshot.Media.Misses);
Assert.True(await _cache.SetStringAsync("image:track:2", "media"));
Assert.True(await _cache.SetStringAsync("artwork:payload:v1:track-2", "media"));
Assert.Equal(1, await _cache.PurgeMetadataAsync());
Assert.Null(await _cache.GetStringAsync("odesli:track:1"));
Assert.Equal("media", await _cache.GetStringAsync("image:track:2"));
Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:track-1:spotify"));
Assert.Equal("media", await _cache.GetStringAsync("artwork:payload:v1:track-2"));
Assert.Equal(1, await _cache.PurgeAllAsync());
Assert.Null(await _cache.GetStringAsync("image:track:2"));
Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:track-2"));
}
[Fact]
@@ -140,11 +140,11 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime
.ExtensionStorage.ActiveExtensions);
Assert.IsType<BadRequestObjectResult>(await controller.Purge("arbitrary:*"));
Assert.True(await _cache.SetStringAsync("lyrics:fixture", "lyrics"));
Assert.True(await _cache.SetStringAsync("odesli:fixture", "provider"));
Assert.True(await _cache.SetStringAsync("lyrics:v2:fixture", "lyrics"));
Assert.True(await _cache.SetStringAsync("odesli:translate:v2:fixture:spotify", "provider"));
Assert.IsType<OkObjectResult>(await controller.PurgeCategory("lyrics"));
Assert.Null(await _cache.GetStringAsync("lyrics:fixture"));
Assert.Equal("provider", await _cache.GetStringAsync("odesli:fixture"));
Assert.Null(await _cache.GetStringAsync("lyrics:v2:fixture"));
Assert.Equal("provider", await _cache.GetStringAsync("odesli:translate:v2:fixture:spotify"));
Assert.IsType<BadRequestObjectResult>(await controller.PurgeCategory("not-a-category"));
Assert.IsType<BadRequestObjectResult>(await controller.PurgeCategory("0"));
}
@@ -153,11 +153,11 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime
public async Task MaintenancePreviewAndRunCoverMetadataAndMediaTiers()
{
Assert.True(await _cache.SetStringAsync(
"search:expired",
"search:v2:expired",
"metadata",
TimeSpan.FromMinutes(1)));
Assert.True(await _cache.SetStringAsync(
"image:expired",
"artwork:payload:v1:expired",
"media",
TimeSpan.FromMinutes(1)));
_clock.UtcNow = _clock.UtcNow.AddMinutes(2);
+18 -22
View File
@@ -19,9 +19,9 @@ public class CacheKeyBuilderTests
true,
"1635cd7d23144ba08251ebe22a56119e");
Assert.Equal(
"search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e:",
key);
Assert.StartsWith("search:v2:", key, StringComparison.Ordinal);
Assert.DoesNotContain("data", key, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("1635cd7d", key, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -52,25 +52,17 @@ public class CacheKeyBuilderTests
"true");
Assert.NotEqual(normalKey, favoritesOnlyKey);
Assert.EndsWith(":false", normalKey);
Assert.EndsWith(":true", favoritesOnlyKey);
}
[Fact]
public void SearchKey_OldOverload_ShouldRemainCompatible()
{
Assert.Equal("search:data:Audio:500:0", CacheKeyBuilder.BuildSearchKey("DATA", "Audio", 500, 0));
Assert.StartsWith("search:v2:", normalKey, StringComparison.Ordinal);
Assert.StartsWith("search:v2:", favoritesOnlyKey, StringComparison.Ordinal);
}
[Fact]
public void LyricsAndGenreKeys_ShouldMatchExpectedFormats()
{
Assert.Equal("lyrics:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsKey("Artist", "Title", "Album", 240));
Assert.Equal("lyricsplus:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsPlusKey("Artist", "Title", "Album", 240));
Assert.Equal("lyrics:id:42", CacheKeyBuilder.BuildLyricsByIdKey(42));
Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track", "Artist"));
Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track:Artist"));
Assert.StartsWith("lyrics:v2:", CacheKeyBuilder.BuildLyricsKey("Artist", "Title", "Album", 240));
Assert.StartsWith("lyricsplus:v2:", CacheKeyBuilder.BuildLyricsPlusKey("Artist", "Title", "Album", 240));
Assert.Equal("lyrics:id:v2:42", CacheKeyBuilder.BuildLyricsByIdKey(42));
Assert.StartsWith("genre:v2:", CacheKeyBuilder.BuildGenreEnrichmentKey("Track:Artist"));
Assert.Equal(
ApplicationCacheCategory.CanonicalMetadata,
ApplicationCachePolicyRegistry.Classify(CacheKeyBuilder.BuildAlbumKey("qobuz", "42")));
@@ -86,11 +78,11 @@ public class CacheKeyBuilderTests
[Fact]
public void MusicBrainzAndOdesliKeys_ShouldMatchExpectedFormats()
{
Assert.Equal("musicbrainz:isrc:USABC123", CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123"));
Assert.Equal("musicbrainz:search:title:artist:5", CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5));
Assert.Equal("musicbrainz:mbid:abc-def", CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def"));
Assert.Equal("musicbrainz:isrc:v1:usabc123", CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123"));
Assert.StartsWith("musicbrainz:search:v1:", CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5));
Assert.Equal("musicbrainz:mbid:v1:abc-def", CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def"));
Assert.Equal("odesli:tidal-to-spotify:123", CacheKeyBuilder.BuildOdesliTidalToSpotifyKey("123"));
Assert.StartsWith("odesli:tidal-to-spotify:v2:", CacheKeyBuilder.BuildOdesliTidalToSpotifyKey("123"));
var urlKey = CacheKeyBuilder.BuildOdesliUrlToSpotifyKey("https://example.com/track?token=secret");
Assert.StartsWith("odesli:url-to-spotify:v2:", urlKey, StringComparison.Ordinal);
Assert.DoesNotContain("example.com", urlKey, StringComparison.Ordinal);
@@ -136,7 +128,8 @@ public class CacheKeyBuilderTests
{
var key = CacheKeyBuilder.BuildPlaybackMetadataNegativeKey("jellyfin", "track-1");
Assert.Equal("negative:playback:metadata:jellyfin:track-1", key);
Assert.StartsWith("negative:playback:metadata:v1:jellyfin:", key);
Assert.DoesNotContain("track-1", key, StringComparison.Ordinal);
Assert.Equal(
ApplicationCacheCategory.NegativeResult,
ApplicationCachePolicyRegistry.Classify(key));
@@ -152,6 +145,9 @@ public class CacheKeyBuilderTests
"odesli:translate:v2:fixture:spotify",
out var category));
Assert.Equal(ApplicationCacheCategory.ProviderResponse, category);
Assert.False(ApplicationCachePolicyRegistry.TryClassify(
"lyrics:Artist:Title:Album:240",
out _));
}
[Fact]
@@ -145,7 +145,7 @@ public class ConfigControllerAuthorizationTests : IAsyncLifetime
var envPath = Path.Combine(_root, ".env");
await File.WriteAllTextAsync(envPath, "CACHE_LYRICS_DAYS=14\n");
var cache = new TestMemoryApplicationCache();
await cache.SetStringAsync("lyrics:fixture", "cached");
await cache.SetStringAsync("lyrics:v2:fixture", "cached");
var controller = CreateController(
CreateHttpContextWithSession(isAdmin: true),
applicationCache: cache);
@@ -162,7 +162,7 @@ public class ConfigControllerAuthorizationTests : IAsyncLifetime
Assert.Equal(_tenantId, setting.TenantId);
Assert.Equal("Cache:LyricsDays", setting.Key);
Assert.Equal("45", setting.ValueJson);
Assert.False(await cache.ExistsAsync("lyrics:fixture"));
Assert.False(await cache.ExistsAsync("lyrics:v2:fixture"));
var getResult = Assert.IsType<OkObjectResult>(await controller.GetConfig());
using var config = JsonDocument.Parse(JsonSerializer.Serialize(
+41 -47
View File
@@ -32,10 +32,10 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task SetAndGet_OverwriteOneDisposableEntry()
{
Assert.True(await _cache.SetStringAsync("playback:metadata:test:1", "first", TimeSpan.FromMinutes(5)));
Assert.True(await _cache.SetStringAsync("playback:metadata:test:1", "second", TimeSpan.FromMinutes(10)));
Assert.True(await _cache.SetStringAsync("playback:metadata:v1:test:1", "first", TimeSpan.FromMinutes(5)));
Assert.True(await _cache.SetStringAsync("playback:metadata:v1:test:1", "second", TimeSpan.FromMinutes(10)));
Assert.Equal("second", await _cache.GetStringAsync("playback:metadata:test:1"));
Assert.Equal("second", await _cache.GetStringAsync("playback:metadata:v1:test:1"));
await using var database = await _factory.CreateDbContextAsync();
var entry = await database.ApplicationCacheEntries.SingleAsync();
@@ -46,11 +46,11 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task ReadsFlushOneSampledAccessTimestamp()
{
await _cache.SetStringAsync("search:touched", "value", TimeSpan.FromHours(1));
await _cache.SetStringAsync("search:v2:touched", "value", TimeSpan.FromHours(1));
_clock.UtcNow = _clock.UtcNow.AddMinutes(5);
Assert.Equal("value", await _cache.GetStringAsync("search:touched"));
Assert.Equal("value", await _cache.GetStringAsync("search:touched"));
Assert.Equal("value", await _cache.GetStringAsync("search:v2:touched"));
Assert.Equal("value", await _cache.GetStringAsync("search:v2:touched"));
Assert.Equal(1, await _cache.FlushAccessesAsync());
await using var database = await _factory.CreateDbContextAsync();
@@ -63,11 +63,11 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task ExpiredEntry_IsAColdMissAndIsRemoved()
{
await _cache.SetStringAsync("odesli:playlist-artwork:1", "asset", TimeSpan.FromMinutes(1));
await _cache.SetStringAsync("odesli:translate:v2:expired:spotify", "asset", TimeSpan.FromMinutes(1));
_clock.UtcNow = _clock.UtcNow.AddMinutes(2);
Assert.Null(await _cache.GetStringAsync("odesli:playlist-artwork:1"));
Assert.False(await _cache.ExistsAsync("odesli:playlist-artwork:1"));
Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:expired:spotify"));
Assert.False(await _cache.ExistsAsync("odesli:translate:v2:expired:spotify"));
await using var database = await _factory.CreateDbContextAsync();
Assert.Empty(await database.ApplicationCacheEntries.ToListAsync());
@@ -76,31 +76,31 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task CleanupExpired_IsBoundedAndLeavesLiveEntries()
{
await _cache.SetStringAsync("odesli:expired:1", "one", TimeSpan.FromMinutes(1));
await _cache.SetStringAsync("odesli:expired:2", "two", TimeSpan.FromMinutes(1));
await _cache.SetStringAsync("odesli:live:1", "live", TimeSpan.FromHours(1));
await _cache.SetStringAsync("odesli:translate:v2:expired-1:spotify", "one", TimeSpan.FromMinutes(1));
await _cache.SetStringAsync("odesli:translate:v2:expired-2:spotify", "two", TimeSpan.FromMinutes(1));
await _cache.SetStringAsync("odesli:translate:v2:live:spotify", "live", TimeSpan.FromHours(1));
_clock.UtcNow = _clock.UtcNow.AddMinutes(2);
Assert.Equal(1, await _cache.CleanupExpiredAsync(batchSize: 1));
await using var database = await _factory.CreateDbContextAsync();
Assert.Equal(2, await database.ApplicationCacheEntries.CountAsync());
Assert.True(await database.ApplicationCacheEntries.AnyAsync(item => item.Key == "odesli:live:1"));
Assert.True(await database.ApplicationCacheEntries.AnyAsync(item => item.Key == "odesli:translate:v2:live:spotify"));
}
[Fact]
public async Task MaintenancePreview_ReportsAndCleanupRemovesOnlyDisposableEntries()
{
await _cache.SetStringAsync("search:live", "live", TimeSpan.FromHours(1));
await _cache.SetStringAsync("search:expired", "expired", TimeSpan.FromMinutes(1));
await _cache.SetStringAsync("search:v2:live", "live", TimeSpan.FromHours(1));
await _cache.SetStringAsync("search:v2:expired", "expired", TimeSpan.FromMinutes(1));
_clock.UtcNow = _clock.UtcNow.AddMinutes(2);
await using (var database = await _factory.CreateDbContextAsync())
{
database.ApplicationCacheEntries.Add(new ApplicationCacheEntryRecord
{
Key = "legacy:no-owner",
Category = "Legacy",
Key = "lyrics:Artist:Title:Album:240",
Category = ApplicationCacheCategory.Lyrics.ToString(),
Value = "orphan",
PayloadBytes = 6,
CreatedAt = _clock.UtcNow,
@@ -136,7 +136,7 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
Assert.Equal(1, await _cache.CleanupExpiredAsync());
Assert.Equal(3, await _cache.CleanupInvalidOwnershipAsync());
Assert.Equal("live", await _cache.GetStringAsync("search:live"));
Assert.Equal("live", await _cache.GetStringAsync("search:v2:live"));
await using var remaining = await _factory.CreateDbContextAsync();
Assert.Single(await remaining.ApplicationCacheEntries.ToListAsync());
@@ -163,15 +163,12 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task PatternOperations_UseRedisCompatibleWildcards()
{
await _cache.SetStringAsync("odesli:playlist:one", "1");
await _cache.SetStringAsync("odesli:playlist:two", "2");
await _cache.SetStringAsync("odesli:track:one", "3");
await _cache.SetStringAsync("odesli:translate:v2:playlist-one:spotify", "1");
await _cache.SetStringAsync("odesli:translate:v2:playlist-two:spotify", "2");
await _cache.SetStringAsync("odesli:translate:v2:track-one:spotify", "3");
Assert.Equal(
["odesli:playlist:one", "odesli:playlist:two"],
_cache.GetKeysByPattern("odesli:playlist:*").Order(StringComparer.Ordinal).ToArray());
Assert.Equal(2, await _cache.DeleteByPatternAsync("odesli:playlist:*"));
Assert.Equal("3", await _cache.GetStringAsync("odesli:track:one"));
Assert.Equal(2, await _cache.DeleteByPatternAsync("odesli:translate:v2:playlist-*:spotify"));
Assert.Equal("3", await _cache.GetStringAsync("odesli:translate:v2:track-one:spotify"));
}
[Fact]
@@ -179,19 +176,16 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
{
var payload = new string('x', DatabaseApplicationCache.MaximumPayloadBytes + 1);
Assert.False(await _cache.SetStringAsync("odesli:too-large", payload));
Assert.False(await _cache.SetStringAsync("odesli:translate:v2:too-large:spotify", payload));
await using var database = await _factory.CreateDbContextAsync();
Assert.Empty(await database.ApplicationCacheEntries.ToListAsync());
}
[Theory]
[InlineData("image:jellyfin:primary:track")]
[InlineData("playlist:image:release-radar")]
[InlineData("artwork:spotify:album")]
[InlineData("cover:qobuz:playlist")]
public async Task MediaPayloadKey_IsRejectedWithoutWriting(string key)
[Fact]
public async Task MediaPayloadKey_IsRejectedWithoutWriting()
{
const string key = "artwork:payload:v1:fixture";
Assert.False(await _cache.SetStringAsync(key, "base64-or-binary-json"));
await using var database = await _factory.CreateDbContextAsync();
@@ -211,7 +205,7 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task DisabledCategory_RejectsAccessAndCleanupRemovesExistingEntries()
{
Assert.True(await _cache.SetStringAsync("lyrics:disabled-fixture", "lyrics"));
Assert.True(await _cache.SetStringAsync("lyrics:v2:disabled-fixture", "lyrics"));
var disabledSettings = new CacheSettings
{
@@ -226,9 +220,9 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
NullLogger<DatabaseApplicationCache>.Instance,
Options.Create(disabledSettings));
Assert.Null(await disabledCache.GetStringAsync("lyrics:disabled-fixture"));
Assert.False(await disabledCache.ExistsAsync("lyrics:disabled-fixture"));
Assert.False(await disabledCache.SetStringAsync("lyrics:new", "blocked"));
Assert.Null(await disabledCache.GetStringAsync("lyrics:v2:disabled-fixture"));
Assert.False(await disabledCache.ExistsAsync("lyrics:v2:disabled-fixture"));
Assert.False(await disabledCache.SetStringAsync("lyrics:v2:new", "blocked"));
Assert.Equal(1, await disabledCache.CleanupPolicyOverflowAsync());
await using var database = await _factory.CreateDbContextAsync();
@@ -252,13 +246,13 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
Options.Create(settings));
var payload = new string('x', 600 * 1024);
Assert.True(await cache.SetStringAsync("odesli:provider:first", payload, TimeSpan.FromHours(1)));
Assert.True(await cache.SetStringAsync("odesli:translate:v2:first:spotify", payload, TimeSpan.FromHours(1)));
_clock.UtcNow = _clock.UtcNow.AddSeconds(1);
Assert.True(await cache.SetStringAsync("odesli:provider:second", payload, TimeSpan.FromHours(1)));
Assert.True(await cache.SetStringAsync("odesli:translate:v2:second:spotify", payload, TimeSpan.FromHours(1)));
Assert.Equal(1, await cache.CleanupPolicyOverflowAsync());
Assert.Null(await cache.GetStringAsync("odesli:provider:first"));
Assert.Equal(payload, await cache.GetStringAsync("odesli:provider:second"));
Assert.Null(await cache.GetStringAsync("odesli:translate:v2:first:spotify"));
Assert.Equal(payload, await cache.GetStringAsync("odesli:translate:v2:second:spotify"));
}
[Fact]
@@ -277,12 +271,12 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
NullLogger<DatabaseApplicationCache>.Instance,
Options.Create(settings));
Assert.True(await cache.SetStringAsync("search:first", "one", TimeSpan.FromHours(1)));
Assert.True(await cache.SetStringAsync("search:v2:first", "one", TimeSpan.FromHours(1)));
_clock.UtcNow = _clock.UtcNow.AddSeconds(1);
Assert.True(await cache.SetStringAsync("search:second", "two", TimeSpan.FromHours(1)));
Assert.True(await cache.SetStringAsync("search:v2:second", "two", TimeSpan.FromHours(1)));
_clock.UtcNow = _clock.UtcNow.AddSeconds(1);
Assert.True(await cache.SetStringAsync("search:third", "three", TimeSpan.FromHours(1)));
Assert.True(await cache.SetStringAsync("lyrics:fixture", "lyrics", TimeSpan.FromHours(1)));
Assert.True(await cache.SetStringAsync("search:v2:third", "three", TimeSpan.FromHours(1)));
Assert.True(await cache.SetStringAsync("lyrics:v2:fixture", "lyrics", TimeSpan.FromHours(1)));
Assert.Equal(1, await cache.CleanupPolicyOverflowAsync());
@@ -290,10 +284,10 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
var entries = await database.ApplicationCacheEntries
.OrderBy(item => item.Key)
.ToListAsync();
Assert.DoesNotContain(entries, item => item.Key == "search:first");
Assert.DoesNotContain(entries, item => item.Key == "search:v2:first");
Assert.Equal(2, entries.Count(item => item.Category == nameof(ApplicationCacheCategory.SearchResults)));
Assert.Contains(entries, item =>
item.Key == "lyrics:fixture" &&
item.Key == "lyrics:v2:fixture" &&
item.Category == nameof(ApplicationCacheCategory.Lyrics));
}
+2 -2
View File
@@ -20,7 +20,7 @@ internal sealed class DisabledApplicationCache : IApplicationCache
public Task<bool> ExistsAsync(string key) => Task.FromResult(false);
public IEnumerable<string> GetKeysByPattern(string pattern) => [];
public Task<int> DeleteByPatternAsync(string pattern) => Task.FromResult(0);
public Task<int> PurgeAllAsync() => Task.FromResult(0);
}
@@ -23,9 +23,9 @@ public sealed class FileMediaApplicationCacheTests : IAsyncLifetime
}
[Theory]
[InlineData("image:jellyfin:primary:../track")]
[InlineData("playlist:image:release/radar")]
[InlineData("artwork:spotify:album")]
[InlineData("artwork:payload:v1:../track")]
[InlineData("artwork:payload:v1:release/radar")]
[InlineData("artwork:payload:v1:album")]
public async Task MediaKey_RoundTripsThroughHashedPaths(string key)
{
Assert.True(await _cache.SetStringAsync(key, "\"image-bytes\"", TimeSpan.FromHours(1)));
@@ -47,39 +47,39 @@ public sealed class FileMediaApplicationCacheTests : IAsyncLifetime
[Fact]
public async Task ExpiredEntry_IsRemovedOnRead()
{
await _cache.SetStringAsync("image:expired", "value", TimeSpan.FromMinutes(1));
await _cache.SetStringAsync("artwork:payload:v1:expired", "value", TimeSpan.FromMinutes(1));
_clock.UtcNow = _clock.UtcNow.AddMinutes(2);
Assert.Null(await _cache.GetStringAsync("image:expired"));
Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:expired"));
Assert.Empty(Directory.GetFiles(_root, "*", SearchOption.AllDirectories));
}
[Fact]
public async Task Quota_RemovesOldestEntry()
{
await _cache.SetStringAsync("image:first", new string('a', 40));
await _cache.SetStringAsync("artwork:payload:v1:first", new string('a', 40));
_clock.UtcNow = _clock.UtcNow.AddSeconds(1);
await _cache.SetStringAsync("image:second", new string('b', 40));
await _cache.SetStringAsync("artwork:payload:v1:second", new string('b', 40));
Assert.Null(await _cache.GetStringAsync("image:first"));
Assert.Equal(new string('b', 40), await _cache.GetStringAsync("image:second"));
Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:first"));
Assert.Equal(new string('b', 40), await _cache.GetStringAsync("artwork:payload:v1:second"));
}
[Fact]
public async Task PatternDelete_RemovesOnlyMatchingMediaKeys()
{
await _cache.SetStringAsync("image:one", "one");
await _cache.SetStringAsync("playlist:image:two", "two");
await _cache.SetStringAsync("artwork:payload:v1:track-one", "one");
await _cache.SetStringAsync("artwork:payload:v1:playlist-two", "two");
Assert.Equal(1, await _cache.DeleteByPatternAsync("playlist:image:*"));
Assert.Equal("one", await _cache.GetStringAsync("image:one"));
Assert.Null(await _cache.GetStringAsync("playlist:image:two"));
Assert.Equal(1, await _cache.DeleteByPatternAsync("artwork:payload:v1:playlist-*"));
Assert.Equal("one", await _cache.GetStringAsync("artwork:payload:v1:track-one"));
Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:playlist-two"));
}
[Fact]
public async Task Cleanup_RemovesMalformedOrphanedAndTemporaryFiles()
{
await _cache.SetStringAsync("image:malformed", "value");
await _cache.SetStringAsync("artwork:payload:v1:malformed", "value");
var entryFiles = Directory.GetFiles(_root, "*", SearchOption.AllDirectories);
var metadataPath = Assert.Single(
entryFiles,
+150 -19
View File
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using allstarr.Core.Capabilities;
using allstarr.Core.Configuration;
using allstarr.Core.Favorites;
using allstarr.Core.Jobs;
@@ -654,25 +655,145 @@ public sealed class PostgresStorageIntegrationTests
var clock = new FixedClock(now);
var tenantId = Guid.CreateVersion7();
var userId = Guid.CreateVersion7();
var accountId = Guid.CreateVersion7();
var recordingId = Guid.CreateVersion7();
var providerIdentityId = Guid.CreateVersion7();
var linkId = Guid.CreateVersion7();
var snapshotId = Guid.CreateVersion7();
var firstExternalId = Guid.CreateVersion7();
var secondExternalId = Guid.CreateVersion7();
const string hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
await using (var seed = await factory.CreateDbContextAsync())
{
seed.Tenants.Add(new TenantRecord
{
Id = tenantId,
Slug = "cache-loss",
Name = "Cache loss",
CreatedAt = now
});
seed.Users.Add(new PlatformUserRecord
{
Id = userId,
TenantId = tenantId,
DisplayName = "Cache owner",
Status = PlatformUserStatus.Active,
CreatedAt = now,
UpdatedAt = now
});
seed.AddRange(
new TenantRecord
{
Id = tenantId,
Slug = "cache-loss",
Name = "Cache loss",
CreatedAt = now
},
new PlatformUserRecord
{
Id = userId,
TenantId = tenantId,
DisplayName = "Cache owner",
Status = PlatformUserStatus.Active,
CreatedAt = now,
UpdatedAt = now
},
new ProviderAccountRecord
{
Id = accountId,
TenantId = tenantId,
OwnerUserId = userId,
ProviderId = "fixture",
DisplayName = "Fixture",
Scope = ProviderAccountScope.User,
Enabled = true,
CreatedAt = now,
UpdatedAt = now
},
new CanonicalRecordingRecord
{
Id = recordingId,
TenantId = tenantId,
CreatedByUserId = userId,
CreatedAt = now,
UpdatedAt = now
},
new ProviderTrackIdentityRecord
{
Id = providerIdentityId,
TenantId = tenantId,
CanonicalRecordingId = recordingId,
ProviderAccountId = accountId,
ProviderId = "fixture",
ResourceKind = ProviderResourceKind.Track,
Scope = ProviderIdentityScope.Account,
ExternalId = "track",
ExternalIdHash = hash,
Verification = ProviderIdentityVerification.Verified,
VerificationMethod = "fixture",
DecisionVersion = 1,
VerifiedAt = now,
CreatedAt = now,
UpdatedAt = now
},
new PlaylistLinkRecord
{
Id = linkId,
TenantId = tenantId,
OwnerUserId = userId,
ProviderAccountId = accountId,
LibraryScopeId = "music",
SourceProviderId = "fixture",
SourcePlaylistId = "playlist",
SourcePlaylistIdHash = hash,
TargetProtocol = "jellyfin",
TargetBackendInstanceId = "home",
Mode = PlaylistLinkMode.Materialized,
MaterializationMode = PlaylistMaterializationMode.Reconcile,
RuleVersion = "rules-v1",
PolicyVersion = "policy-v1",
CreatedAt = now,
UpdatedAt = now
},
External(firstExternalId, 1),
External(secondExternalId, 2),
new PlaylistSourceSnapshotRecord
{
Id = snapshotId,
TenantId = tenantId,
OwnerUserId = userId,
PlaylistLinkId = linkId,
ProviderAccountId = accountId,
SnapshotVersion = 1,
ProviderRevision = "revision",
Name = "Ordered",
PayloadSha256 = hash,
CorrelationId = "cache-loss",
RetrievedAt = now
},
new PlaylistSourceEntryRecord
{
Id = Guid.CreateVersion7(),
TenantId = tenantId,
PlaylistSourceSnapshotId = snapshotId,
ExternalMetadataSnapshotId = firstExternalId,
SourcePosition = 0,
SourceEntryIdHash = hash
},
new PlaylistSourceEntryRecord
{
Id = Guid.CreateVersion7(),
TenantId = tenantId,
PlaylistSourceSnapshotId = snapshotId,
ExternalMetadataSnapshotId = secondExternalId,
SourcePosition = 1,
SourceEntryIdHash = new string('b', 64)
});
await seed.SaveChangesAsync();
ExternalMetadataSnapshotRecord External(Guid id, int version) => new()
{
Id = id,
TenantId = tenantId,
OwnerUserId = userId,
ProviderAccountId = accountId,
LibraryScopeId = "music",
BackendInstanceId = "home",
BackendPrincipalId = "principal",
Protocol = "jellyfin",
ProviderId = "fixture",
ResourceKind = "track",
ExternalIdHash = new string((char)('a' + version), 64),
SnapshotVersion = 1,
ProviderRevision = $"revision-{version}",
PayloadSha256 = new string((char)('c' + version), 64),
CorrelationId = "cache-loss",
RetrievedAt = now
};
}
var jobOptions = new DurableJobOptions();
@@ -703,10 +824,10 @@ public sealed class PostgresStorageIntegrationTests
clock,
NullLogger<DatabaseApplicationCache>.Instance);
Assert.True(await firstCache.SetStringAsync(
"search:cache-loss",
"search:v2:cache-loss",
"disposable",
TimeSpan.FromHours(1)));
Assert.Equal("disposable", await firstCache.GetStringAsync("search:cache-loss"));
Assert.Equal("disposable", await firstCache.GetStringAsync("search:v2:cache-loss"));
await using (var purge = await factory.CreateDbContextAsync())
{
@@ -717,9 +838,19 @@ public sealed class PostgresStorageIntegrationTests
factory,
clock,
NullLogger<DatabaseApplicationCache>.Instance);
Assert.Null(await restartedCache.GetStringAsync("search:cache-loss"));
Assert.Null(await restartedCache.GetStringAsync("search:v2:cache-loss"));
await using var verification = await factory.CreateDbContextAsync();
Assert.True(await verification.Jobs.AnyAsync(item => item.Id == enqueued.JobId));
Assert.True(await verification.CanonicalRecordings.AnyAsync(item => item.Id == recordingId));
Assert.True(await verification.ProviderTrackIdentities.AnyAsync(item =>
item.Id == providerIdentityId &&
item.CanonicalRecordingId == recordingId));
var positions = await verification.PlaylistSourceEntries
.Where(item => item.PlaylistSourceSnapshotId == snapshotId)
.OrderBy(item => item.SourcePosition)
.Select(item => item.SourcePosition)
.ToArrayAsync();
Assert.Equal(new[] { 0, 1 }, positions);
Assert.True(await verification.AuditEvents.AnyAsync(item =>
item.Category == "job-progress" &&
item.CorrelationId == claim!.CorrelationId));
@@ -52,5 +52,7 @@ internal sealed class TestMemoryApplicationCache : IApplicationCache
return Task.FromResult(deleted);
}
public Task<int> PurgeAllAsync() => DeleteByPatternAsync("*");
private sealed record Entry(string Value, DateTimeOffset? ExpiresAt);
}
+3 -16
View File
@@ -353,7 +353,7 @@ public class ConfigController : ControllerBase
var cacheEntriesInvalidated = normalized.Any(item =>
!item.DurableKey.StartsWith("Scrobbling:", StringComparison.OrdinalIgnoreCase) &&
!item.DurableKey.StartsWith("WebUi:", StringComparison.OrdinalIgnoreCase))
? await _cache.DeleteByPatternAsync("*")
? await _cache.PurgeAllAsync()
: 0;
return Ok(new
@@ -392,21 +392,8 @@ public class ConfigController : ControllerBase
{
_logger.LogDebug("Cache clear requested from admin UI");
var clearedCacheEntries = 0;
// Clear all search cache keys (pattern-based deletion)
var searchKeysDeleted =
await _cache.DeleteByPatternAsync(CacheKeyBuilder.BuildSearchPattern());
clearedCacheEntries += searchKeysDeleted;
// Clear all media descriptor and content-addressed payload keys.
var imageKeysDeleted =
await _cache.DeleteByPatternAsync(CacheKeyBuilder.BuildMediaDescriptorPattern()) +
await _cache.DeleteByPatternAsync(CacheKeyBuilder.BuildMediaPayloadPattern());
clearedCacheEntries += imageKeysDeleted;
_logger.LogInformation("Cache cleared: {Entries} derived entries (including {SearchKeys} search keys, {ImageKeys} image keys)",
clearedCacheEntries, searchKeysDeleted, imageKeysDeleted);
var clearedCacheEntries = await _cache.PurgeAllAsync();
_logger.LogInformation("Cache cleared: {Entries} disposable entries", clearedCacheEntries);
return Ok(new
{
@@ -60,38 +60,40 @@ public static class ApplicationCachePolicyRegistry
ArgumentException.ThrowIfNullOrWhiteSpace(key);
category = default;
if (StartsWithAny(key, "image:", "playlist:image:", "artwork:", "cover:"))
if (CacheKeyBuilder.IsMediaAssetPayloadKey(key))
category = ApplicationCacheCategory.Artwork;
if (StartsWithAny(key, "lyrics:", "lyricsplus:"))
if (StartsWithAny(key, "lyrics:v2:", "lyrics:id:v2:", "lyricsplus:v2:"))
category = ApplicationCacheCategory.Lyrics;
if (StartsWithAny(key, "transcode:", "temporary-audio:"))
category = ApplicationCacheCategory.TemporaryAudio;
if (key.StartsWith("negative:", StringComparison.OrdinalIgnoreCase))
if (key.StartsWith("negative:playback:metadata:v1:", StringComparison.Ordinal))
category = ApplicationCacheCategory.NegativeResult;
if (StartsWithAny(key, "playback:signal:", "cts:rotation:"))
if (key.StartsWith("playback:signal:dedupe:v1:", StringComparison.Ordinal))
category = ApplicationCacheCategory.Coordination;
if (key.StartsWith("admin:playlists:", StringComparison.OrdinalIgnoreCase))
category = ApplicationCacheCategory.DerivedProjection;
if (StartsWithAny(
key,
"media:descriptor:",
"playlist:artwork-descriptor:",
"musicbrainz:",
"genre:",
"playback:metadata:"))
"media:descriptor:v3:",
"playlist:artwork-descriptor:v1:",
"metadata:album:v1:",
"metadata:artist:v1:",
"musicbrainz:isrc:v1:",
"musicbrainz:search:v1:",
"musicbrainz:mbid:v1:",
"genre:v2:",
"playback:metadata:v1:"))
category = ApplicationCacheCategory.CanonicalMetadata;
if (key.Contains(":album:", StringComparison.OrdinalIgnoreCase) ||
key.Contains(":artist:", StringComparison.OrdinalIgnoreCase))
category = ApplicationCacheCategory.CanonicalMetadata;
if (key.StartsWith("search:", StringComparison.OrdinalIgnoreCase))
if (key.StartsWith("search:v2:", StringComparison.Ordinal))
category = ApplicationCacheCategory.SearchResults;
if (key.StartsWith("playlist:discovery:", StringComparison.OrdinalIgnoreCase))
if (key.StartsWith("playlist:discovery:v2:", StringComparison.Ordinal))
category = ApplicationCacheCategory.PlaylistDiscovery;
if (StartsWithAny(key, "odesli:", "jellyfin:item-type:"))
if (StartsWithAny(
key,
"odesli:tidal-to-spotify:v2:",
"odesli:url-to-spotify:v2:",
"odesli:translate:v2:",
"jellyfin:item-type:v1:"))
category = ApplicationCacheCategory.ProviderResponse;
return category != default ||
key.StartsWith("search:", StringComparison.OrdinalIgnoreCase);
key.StartsWith("search:v2:", StringComparison.Ordinal);
}
public static ApplicationCacheCategoryPolicy Resolve(string key, CacheSettings? settings = null) =>
@@ -131,9 +131,6 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable
return await _database.ExistsAsync(key);
}
public IEnumerable<string> GetKeysByPattern(string pattern) =>
_database.GetKeysByPattern(pattern);
public async Task<int> DeleteByPatternAsync(string pattern)
{
_memory.Clear();
@@ -141,6 +138,13 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable
return await _database.DeleteByPatternAsync(pattern);
}
public async Task<int> PurgeAllAsync()
{
_memory.Clear();
Interlocked.Add(ref _evictions, ClearResidents());
return await _database.PurgeAllAsync();
}
public async Task<int> DeleteCategoryAsync(ApplicationCacheCategory category)
{
_memory.Clear();
+47 -56
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
@@ -10,29 +11,25 @@ namespace allstarr.Services.Common;
public static class CacheKeyBuilder
{
public static string BuildPlaybackMetadataKey(string provider, string itemId) =>
$"playback:metadata:{Normalize(provider)}:{Normalize(itemId)}";
$"playback:metadata:v1:{Normalize(provider)}:{Digest(itemId.Trim())}";
public static string BuildPlaybackMetadataNegativeKey(string provider, string itemId) =>
$"negative:playback:metadata:{Normalize(provider)}:{Normalize(itemId)}";
$"negative:playback:metadata:v1:{Normalize(provider)}:{Digest(itemId.Trim())}";
public static string BuildJellyfinItemTypeKey(string itemId) =>
$"jellyfin:item-type:{Normalize(itemId)}";
$"jellyfin:item-type:v1:{Digest(itemId.Trim())}";
public static string BuildPlaybackSignalDedupeKey(
string signalType,
string deviceId,
string itemId)
{
var identity = $"{Normalize(signalType)}\u001f{Normalize(deviceId)}\u001f{Normalize(itemId)}";
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(identity));
return $"playback:signal:dedupe:{Convert.ToHexStringLower(digest)}";
}
string itemId) =>
$"playback:signal:dedupe:v1:{DigestIdentity(signalType, deviceId, itemId)}";
public static string BuildProviderPlaylistArtworkDescriptorKey(
string provider,
string playlistId,
string? revision) =>
$"playlist:artwork-descriptor:{Normalize(provider)}:{Normalize(playlistId)}:{Normalize(revision)}";
$"playlist:artwork-descriptor:v1:{Normalize(provider)}:{Digest(playlistId.Trim())}:{Digest(revision?.Trim() ?? string.Empty)}";
public static string BuildProviderPlaylistDiscoveryKey(
Guid? tenantId,
@@ -44,10 +41,6 @@ public static class CacheKeyBuilder
string? cursor,
int limit)
{
var request = string.Join('\u001f',
query?.Trim(),
cursor?.Trim(),
limit);
return string.Join(':',
"playlist",
"discovery",
@@ -55,9 +48,9 @@ public static class CacheKeyBuilder
tenantId?.ToString("N") ?? "global",
userId?.ToString("N") ?? "shared",
accountId.ToString("N"),
accountRevision,
accountRevision.ToString(CultureInfo.InvariantCulture),
Normalize(providerId),
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(request))));
DigestIdentity(query, cursor, limit));
}
public static string BuildProviderPlaylistDiscoveryAccountPattern(Guid accountId) =>
@@ -69,7 +62,7 @@ public static class CacheKeyBuilder
Encoding.UTF8.GetBytes(identity.ResourceId.Trim())));
var revision = Convert.ToHexStringLower(SHA256.HashData(
Encoding.UTF8.GetBytes(identity.Revision?.Trim() ?? string.Empty)));
var dimensions = $"{identity.Width ?? 0}x{identity.Height ?? 0}";
var dimensions = FormattableString.Invariant($"{identity.Width ?? 0}x{identity.Height ?? 0}");
return string.Join(':',
"media",
"descriptor",
@@ -88,15 +81,10 @@ public static class CacheKeyBuilder
$"media:descriptor:v3:*:*:{accountId:N}:*";
public static string BuildMediaAssetPayloadKey(string sha256) =>
$"artwork:payload:v1:{sha256}";
$"artwork:payload:v1:{Normalize(sha256)}";
#region Search Keys
public static string BuildSearchKey(string? searchTerm, string? itemTypes, int? limit, int? startIndex)
{
return $"search:{searchTerm?.ToLowerInvariant()}:{itemTypes}:{limit}:{startIndex}";
}
public static string BuildSearchKey(
string? searchTerm,
string? itemTypes,
@@ -109,20 +97,19 @@ public static class CacheKeyBuilder
string? userId,
string? isFavorite = null)
{
var normalizedTerm = Normalize(searchTerm);
var normalizedItemTypes = Normalize(itemTypes);
var normalizedParentId = Normalize(parentId);
var normalizedSortBy = Normalize(sortBy);
var normalizedSortOrder = Normalize(sortOrder);
var normalizedUserId = Normalize(userId);
var normalizedIsFavorite = Normalize(isFavorite);
var normalizedRecursive = recursive.HasValue ? (recursive.Value ? "true" : "false") : string.Empty;
return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}:{normalizedIsFavorite}";
return $"search:v2:{DigestIdentity(
searchTerm,
itemTypes,
limit,
startIndex,
parentId,
sortBy,
sortOrder,
recursive,
userId,
isFavorite)}";
}
public static string BuildSearchPattern() => "search:*";
private static string Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value)
@@ -136,12 +123,12 @@ public static class CacheKeyBuilder
public static string BuildAlbumKey(string provider, string externalId)
{
return $"{provider}:album:{externalId}";
return $"metadata:album:v1:{Normalize(provider)}:{Digest(externalId.Trim())}";
}
public static string BuildArtistKey(string provider, string externalId)
{
return $"{provider}:artist:{externalId}";
return $"metadata:artist:v1:{Normalize(provider)}:{Digest(externalId.Trim())}";
}
#endregion
@@ -150,39 +137,36 @@ public static class CacheKeyBuilder
public static string BuildLyricsKey(string artist, string title, string? album, int? durationSeconds)
{
return $"lyrics:{artist}:{title}:{album}:{durationSeconds}";
return $"lyrics:v2:{DigestIdentity(artist, title, album, durationSeconds)}";
}
public static string BuildLyricsPlusKey(string artist, string title, string? album, int? durationSeconds)
{
return $"lyricsplus:{artist}:{title}:{album}:{durationSeconds}";
return $"lyricsplus:v2:{DigestIdentity(artist, title, album, durationSeconds)}";
}
public static string BuildLyricsByIdKey(int id)
{
return $"lyrics:id:{id}";
return $"lyrics:id:v2:{id.ToString(CultureInfo.InvariantCulture)}";
}
#endregion
#region Image Keys
public static string BuildMediaDescriptorPattern() => "media:descriptor:*";
public static bool IsMediaAssetDescriptorKey(string key) =>
key.StartsWith("media:descriptor:v3:", StringComparison.Ordinal);
public static string BuildMediaPayloadPattern() => "artwork:payload:*";
public static bool IsMediaAssetPayloadKey(string key) =>
key.StartsWith("artwork:payload:v1:", StringComparison.Ordinal);
#endregion
#region Genre Keys
public static string BuildGenreEnrichmentKey(string title, string artist)
{
return $"genre:{title}:{artist}";
}
public static string BuildGenreEnrichmentKey(string compositeCacheKey)
{
return $"genre:{compositeCacheKey}";
return $"genre:v2:{Digest(compositeCacheKey.Trim())}";
}
#endregion
@@ -191,17 +175,17 @@ public static class CacheKeyBuilder
public static string BuildMusicBrainzIsrcKey(string isrc)
{
return $"musicbrainz:isrc:{isrc}";
return $"musicbrainz:isrc:v1:{Normalize(isrc)}";
}
public static string BuildMusicBrainzSearchKey(string title, string artist, int limit)
{
return $"musicbrainz:search:{title.ToLowerInvariant()}:{artist.ToLowerInvariant()}:{limit}";
return $"musicbrainz:search:v1:{DigestIdentity(title, artist, limit)}";
}
public static string BuildMusicBrainzMbidKey(string mbid)
{
return $"musicbrainz:mbid:{mbid}";
return $"musicbrainz:mbid:v1:{Normalize(mbid)}";
}
#endregion
@@ -210,7 +194,7 @@ public static class CacheKeyBuilder
public static string BuildOdesliTidalToSpotifyKey(string tidalTrackId)
{
return $"odesli:tidal-to-spotify:{tidalTrackId}";
return $"odesli:tidal-to-spotify:v2:{Digest(tidalTrackId.Trim())}";
}
public static string BuildOdesliUrlToSpotifyKey(string musicUrl)
@@ -220,13 +204,20 @@ public static class CacheKeyBuilder
public static string BuildOdesliTranslationKey(string sourceUrl, string targetPlatform)
{
return $"odesli:translate:v2:{HashOdesliUrl(sourceUrl)}:{targetPlatform.ToLowerInvariant()}";
return $"odesli:translate:v2:{HashOdesliUrl(sourceUrl)}:{Normalize(targetPlatform)}";
}
private static string HashOdesliUrl(string value) =>
Convert.ToHexStringLower(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(value)));
Digest(value);
private static string DigestIdentity(params object?[] values) =>
Digest(string.Join('\u001f', values.Select(value => Normalize(
value is IFormattable formattable
? formattable.ToString(null, CultureInfo.InvariantCulture)
: value?.ToString()))));
private static string Digest(string value) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
#endregion
}
@@ -327,28 +327,6 @@ public sealed class DatabaseApplicationCache(
}
}
public IEnumerable<string> GetKeysByPattern(string pattern)
{
try
{
var now = clock.UtcNow;
using var database = contextFactory.CreateDbContext();
var likePattern = ToLikePattern(pattern);
return database.Set<ApplicationCacheEntryRecord>()
.AsNoTracking()
.Where(item =>
(item.ExpiresAt == null || item.ExpiresAt > now) &&
EF.Functions.Like(item.Key, likePattern, "\\"))
.Select(item => item.Key)
.ToArray();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Database cache key scan failed for pattern {Pattern}", pattern);
return Array.Empty<string>();
}
}
public async Task<int> DeleteByPatternAsync(string pattern)
{
try
@@ -368,6 +346,22 @@ public sealed class DatabaseApplicationCache(
}
}
public async Task<int> PurgeAllAsync()
{
try
{
await using var database = await contextFactory.CreateDbContextAsync();
var deleted = await database.Set<ApplicationCacheEntryRecord>().ExecuteDeleteAsync();
Interlocked.Add(ref _evictions, deleted);
return deleted;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Database cache purge failed");
return 0;
}
}
public async Task<int> DeleteCategoryAsync(ApplicationCacheCategory category)
{
try
@@ -594,7 +588,7 @@ public sealed class DatabaseApplicationCache(
var values = await database.Set<ApplicationCacheEntryRecord>()
.AsNoTracking()
.Where(item =>
item.Key.StartsWith("media:descriptor:") &&
item.Key.StartsWith("media:descriptor:v3:") &&
(item.ExpiresAt == null || item.ExpiresAt > clock.UtcNow))
.OrderBy(item => item.Key)
.Select(item => item.Value)
@@ -787,7 +781,7 @@ public sealed class DatabaseApplicationCache(
!Enum.IsDefined(category) ||
ApplicationCachePolicyRegistry.Resolve(category).StorageTier != ApplicationCacheStorageTier.Metadata ||
expectedCategory != category ||
(item.Key.StartsWith("media:descriptor:", StringComparison.Ordinal) &&
(CacheKeyBuilder.IsMediaAssetDescriptorKey(item.Key) &&
ReadArtworkPayloadKey(item.Value) == null);
private static string? ReadArtworkPayloadKey(string value)
@@ -813,7 +807,7 @@ public sealed class DatabaseApplicationCache(
private static IEnumerable<ApplicationCacheEntryRecord> FindSupersededArtworkDescriptors(
IEnumerable<ApplicationCacheEntryRecord> entries) =>
entries
.Where(item => item.Key.StartsWith("media:descriptor:v3:", StringComparison.Ordinal))
.Where(item => CacheKeyBuilder.IsMediaAssetDescriptorKey(item.Key))
.GroupBy(item => item.Key[..item.Key.LastIndexOf(':')], StringComparer.Ordinal)
.SelectMany(group => group
.OrderByDescending(item => item.UpdatedAt)
@@ -313,29 +313,6 @@ public sealed class FileMediaApplicationCache : IApplicationCache, IDisposable
public async Task<bool> ExistsAsync(string key) =>
await GetStringAsync(key) is not null;
public IEnumerable<string> GetKeysByPattern(string pattern)
{
_gate.Wait();
try
{
return ReadAllMetadata()
.Where(item =>
(item.ExpiresAt is null || item.ExpiresAt > _clock.UtcNow) &&
FileSystemName.MatchesSimpleExpression(pattern, item.Key, ignoreCase: true))
.Select(item => item.Key)
.ToArray();
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Disk media cache key scan failed for pattern {Pattern}", pattern);
return Array.Empty<string>();
}
finally
{
_gate.Release();
}
}
public async Task<int> DeleteByPatternAsync(string pattern)
{
await _gate.WaitAsync();
@@ -366,6 +343,32 @@ public sealed class FileMediaApplicationCache : IApplicationCache, IDisposable
}
}
public async Task<int> PurgeAllAsync()
{
await _gate.WaitAsync();
try
{
if (!Directory.Exists(_options.RootPath)) return 0;
var deleted = Directory.EnumerateFiles(
_options.RootPath,
"*.json",
SearchOption.AllDirectories)
.Count();
Directory.Delete(_options.RootPath, recursive: true);
Interlocked.Add(ref _evictions, deleted);
return deleted;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Disk media cache purge failed");
return 0;
}
finally
{
_gate.Release();
}
}
public async Task<int> DeleteCategoryAsync(ApplicationCacheCategory category)
{
await _gate.WaitAsync();
@@ -568,7 +571,7 @@ public sealed class FileMediaApplicationCache : IApplicationCache, IDisposable
var take = Math.Clamp(maximumEntries, 1, _options.MaximumCleanupFiles);
var entries = ReadAllMetadata()
.Where(item =>
item.Key.StartsWith("artwork:payload:", StringComparison.Ordinal) &&
CacheKeyBuilder.IsMediaAssetPayloadKey(item.Key) &&
item.LastAccessAt <= _clock.UtcNow.AddMinutes(-5) &&
!referencedPayloadKeys.Contains(item.Key))
.OrderBy(item => item.LastAccessAt)
@@ -849,16 +852,8 @@ public sealed class HybridApplicationCache(
? Target(key).ExistsAsync(key)
: Task.FromResult(false);
public IEnumerable<string> GetKeysByPattern(string pattern) =>
metadata.GetKeysByPattern(pattern)
.Concat(media.GetKeysByPattern(pattern))
.Where(IsCategoryEnabled)
.Distinct(StringComparer.Ordinal)
.ToArray();
public async Task<int> DeleteByPatternAsync(string pattern) =>
await metadata.DeleteByPatternAsync(pattern) +
await media.DeleteByPatternAsync(pattern);
public Task<int> DeleteByPatternAsync(string pattern) =>
metadata.DeleteByPatternAsync(pattern);
public async Task<ApplicationCacheDiagnosticsSnapshot> GetDiagnosticsAsync(
CancellationToken cancellationToken = default)
@@ -900,9 +895,9 @@ public sealed class HybridApplicationCache(
};
}
public Task<int> PurgeMetadataAsync() => metadata.DeleteByPatternAsync("*");
public Task<int> PurgeMetadataAsync() => metadata.PurgeAllAsync();
public Task<int> PurgeMediaAsync() => media.DeleteByPatternAsync("*");
public Task<int> PurgeMediaAsync() => media.PurgeAllAsync();
public async Task<ApplicationCacheMaintenancePreview> PreviewMaintenanceAsync(
CancellationToken cancellationToken = default)
@@ -20,7 +20,7 @@ public interface IApplicationCache
Task<bool> ExistsAsync(string key);
IEnumerable<string> GetKeysByPattern(string pattern);
Task<int> DeleteByPatternAsync(string pattern);
Task<int> PurgeAllAsync();
}