mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
refactor(cache): consolidate versioned key namespaces
This commit is contained in:
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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user