feat(cache): prune stale artwork revisions

This commit is contained in:
joshpatra committed 2026-07-26 21:16:31 -04:00
1 parent 7faa367408
commit f5af3e3f4a
7 files changed
+100 -16

No files matched your search

+2 -2
View File
@@ -160,13 +160,13 @@ public class CacheKeyBuilderTests
96));
Assert.StartsWith(
$"media:descriptor:v2:{tenantId:N}:{userId:N}:{accountId:N}:spotify:playlist:",
$"media:descriptor:v3:{tenantId:N}:{userId:N}:{accountId:N}:spotify:playlist:",
key,
StringComparison.Ordinal);
Assert.DoesNotContain("private-playlist-id", key, StringComparison.Ordinal);
Assert.DoesNotContain("signed-revision", key, StringComparison.Ordinal);
Assert.Equal(
$"media:descriptor:v2:*:*:{accountId:N}:*",
$"media:descriptor:v3:*:*:{accountId:N}:*",
CacheKeyBuilder.BuildMediaAssetDescriptorAccountPattern(accountId));
}
}
@@ -91,7 +91,7 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
});
database.ApplicationCacheEntries.Add(new ApplicationCacheEntryRecord
{
Key = "media:descriptor:v2:global:shared:none:jellyfin:playlist:broken",
Key = "media:descriptor:v3:global:shared:none:jellyfin:playlist:resource:0x0:broken",
Category = ApplicationCacheCategory.CanonicalMetadata.ToString(),
Value = "{broken",
PayloadBytes = 7,
@@ -116,6 +116,24 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
Assert.Single(await remaining.ApplicationCacheEntries.ToListAsync());
}
[Fact]
public async Task MaintenanceRemovesOlderArtworkRevisionsDeterministically()
{
var first = CacheKeyBuilder.BuildMediaAssetDescriptorKey(new(
null, null, null, "jellyfin", "playlist", "playlist-1", "revision-1", 96, 96));
var second = CacheKeyBuilder.BuildMediaAssetDescriptorKey(new(
null, null, null, "jellyfin", "playlist", "playlist-1", "revision-2", 96, 96));
const string descriptor = """{"PayloadKey":"artwork:payload:v1:fixture"}""";
Assert.True(await _cache.SetStringAsync(first, descriptor, TimeSpan.FromHours(1)));
_clock.UtcNow = _clock.UtcNow.AddSeconds(1);
Assert.True(await _cache.SetStringAsync(second, descriptor, TimeSpan.FromHours(1)));
Assert.Equal(1, (await _cache.PreviewMaintenanceAsync()).SupersededEntries);
Assert.Equal(1, await _cache.CleanupSupersededArtworkDescriptorsAsync());
Assert.Null(await _cache.GetStringAsync(first));
Assert.Equal(descriptor, await _cache.GetStringAsync(second));
}
[Fact]
public async Task PatternOperations_UseRedisCompatibleWildcards()
{
+3 -3
View File
@@ -45,7 +45,7 @@ public sealed class MediaAssetResolverTests
Assert.All(results, result => Assert.False(result!.FromCache));
Assert.True(cached!.FromCache);
Assert.Equal("\"etag\"", cached.ETag);
Assert.Single(cache.GetKeysByPattern("media:descriptor:v2:*"));
Assert.Single(cache.GetKeysByPattern("media:descriptor:v3:*"));
Assert.Single(cache.GetKeysByPattern("artwork:payload:v1:*"));
}
@@ -65,7 +65,7 @@ public sealed class MediaAssetResolverTests
_ => Task.FromResult<MediaAssetSource?>(new(bytes, "image/jpeg")),
1024);
Assert.Equal(2, cache.GetKeysByPattern("media:descriptor:v2:*").Count());
Assert.Equal(2, cache.GetKeysByPattern("media:descriptor:v3:*").Count());
Assert.Single(cache.GetKeysByPattern("artwork:payload:v1:*"));
Assert.DoesNotContain(cache.GetKeysByPattern("*"), key =>
key.Contains("user-avatar-id", StringComparison.Ordinal));
@@ -96,7 +96,7 @@ public sealed class MediaAssetResolverTests
Assert.Equal(1, resized.Height);
Assert.Equal("image/jpeg", result.ContentType);
Assert.True(cached!.FromCache);
Assert.Equal(2, cache.GetKeysByPattern("media:descriptor:v2:*").Count());
Assert.Equal(2, cache.GetKeysByPattern("media:descriptor:v3:*").Count());
Assert.Equal(2, cache.GetKeysByPattern("artwork:payload:v1:*").Count());
}
@@ -34,6 +34,7 @@ public sealed record DatabaseCacheMaintenancePreview(
int ExpiredEntries,
int UnknownOwnerEntries,
int DisabledCategoryEntries,
int SupersededEntries,
int OverQuotaEntries,
long ReclaimableBytes,
DateTimeOffset CapturedAt);
@@ -164,6 +164,8 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable
cancellationToken: cancellationToken);
deleted += await _database.CleanupInvalidOwnershipAsync(
cancellationToken: cancellationToken);
deleted += await _database.CleanupSupersededArtworkDescriptorsAsync(
cancellationToken: cancellationToken);
deleted += await _database.CleanupPolicyOverflowAsync(
cancellationToken: cancellationToken);
return deleted;
+10 -8
View File
@@ -67,25 +67,27 @@ public static class CacheKeyBuilder
public static string BuildMediaAssetDescriptorKey(MediaAssetIdentity identity)
{
var resource = string.Join('\u001f',
identity.ResourceId.Trim(),
identity.Revision?.Trim(),
identity.Width,
identity.Height);
var resource = Convert.ToHexStringLower(SHA256.HashData(
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}";
return string.Join(':',
"media",
"descriptor",
"v2",
"v3",
identity.TenantId?.ToString("N") ?? "global",
identity.UserId?.ToString("N") ?? "shared",
identity.ProviderAccountId?.ToString("N") ?? "none",
Normalize(identity.ProviderId),
Normalize(identity.ResourceKind),
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(resource))));
resource,
dimensions,
revision);
}
public static string BuildMediaAssetDescriptorAccountPattern(Guid accountId) =>
$"media:descriptor:v2:*:*:{accountId:N}:*";
$"media:descriptor:v3:*:*:{accountId:N}:*";
public static string BuildMediaAssetPayloadKey(string sha256) =>
$"artwork:payload:v1:{sha256}";
@@ -418,7 +418,14 @@ public sealed class DatabaseApplicationCache(
!ApplicationCachePolicyRegistry.IsEnabled(
Enum.Parse<ApplicationCacheCategory>(item.Category, ignoreCase: true),
_settings)).ToArray();
var active = scanned.Except(expired).Except(unknown).Except(disabled).ToArray();
var superseded = FindSupersededArtworkDescriptors(
scanned.Except(expired).Except(unknown).Except(disabled)).ToArray();
var active = scanned
.Except(expired)
.Except(unknown)
.Except(disabled)
.Except(superseded)
.ToArray();
var overQuota = new List<ApplicationCacheEntryRecord>();
foreach (var group in active.GroupBy(item =>
Enum.Parse<ApplicationCacheCategory>(item.Category, ignoreCase: true)))
@@ -443,6 +450,7 @@ public sealed class DatabaseApplicationCache(
var reclaimable = expired
.Concat(unknown)
.Concat(disabled)
.Concat(superseded)
.Concat(overQuota)
.DistinctBy(item => item.Key)
.Sum(item => (long)item.PayloadBytes);
@@ -452,6 +460,7 @@ public sealed class DatabaseApplicationCache(
expired.Length,
unknown.Length,
disabled.Length,
superseded.Length,
overQuota.Count,
reclaimable,
clock.UtcNow);
@@ -460,7 +469,7 @@ public sealed class DatabaseApplicationCache(
{
logger.LogWarning(exception, "Database cache maintenance preview failed");
return new DatabaseCacheMaintenancePreview(
0, false, 0, 0, 0, 0, 0, clock.UtcNow);
0, false, 0, 0, 0, 0, 0, 0, clock.UtcNow);
}
}
@@ -538,6 +547,45 @@ public sealed class DatabaseApplicationCache(
}
}
public async Task<int> CleanupSupersededArtworkDescriptorsAsync(
int batchSize = DefaultCleanupBatchSize,
CancellationToken cancellationToken = default)
{
var take = Math.Clamp(batchSize, 1, DefaultCleanupBatchSize);
try
{
await using var database = await contextFactory.CreateDbContextAsync(cancellationToken);
var descriptors = await database.Set<ApplicationCacheEntryRecord>()
.AsNoTracking()
.Where(item =>
item.Key.StartsWith("media:descriptor:v3:") &&
(item.ExpiresAt == null || item.ExpiresAt > clock.UtcNow))
.OrderBy(item => item.UpdatedAt)
.ThenBy(item => item.Key)
.Take(10_000)
.ToArrayAsync(cancellationToken);
var keys = FindSupersededArtworkDescriptors(descriptors)
.Take(take)
.Select(item => item.Key)
.ToArray();
if (keys.Length == 0)
{
return 0;
}
var deleted = await database.Set<ApplicationCacheEntryRecord>()
.Where(item => keys.Contains(item.Key))
.ExecuteDeleteAsync(cancellationToken);
Interlocked.Add(ref _evictions, deleted);
return deleted;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Superseded artwork descriptor cleanup failed");
return 0;
}
}
public async Task<int> CleanupPolicyOverflowAsync(
int batchSize = DefaultCleanupBatchSize,
CancellationToken cancellationToken = default)
@@ -691,6 +739,16 @@ public sealed class DatabaseApplicationCache(
return null;
}
private static IEnumerable<ApplicationCacheEntryRecord> FindSupersededArtworkDescriptors(
IEnumerable<ApplicationCacheEntryRecord> entries) =>
entries
.Where(item => item.Key.StartsWith("media:descriptor:v3:", StringComparison.Ordinal))
.GroupBy(item => item.Key[..item.Key.LastIndexOf(':')], StringComparer.Ordinal)
.SelectMany(group => group
.OrderByDescending(item => item.UpdatedAt)
.ThenByDescending(item => item.Key, StringComparer.Ordinal)
.Skip(1));
private static string ToLikePattern(string pattern) =>
pattern
.Replace("\\", "\\\\", StringComparison.Ordinal)
@@ -717,6 +775,9 @@ public sealed class DatabaseApplicationCacheCleanupService(
deleted += await cache.CleanupInvalidOwnershipAsync(
DatabaseApplicationCache.DefaultCleanupBatchSize,
stoppingToken);
deleted += await cache.CleanupSupersededArtworkDescriptorsAsync(
DatabaseApplicationCache.DefaultCleanupBatchSize,
stoppingToken);
deleted += await cache.CleanupPolicyOverflowAsync(
DatabaseApplicationCache.DefaultCleanupBatchSize,
stoppingToken);