feat(cache): clean account-scoped artwork

This commit is contained in:
joshpatra committed 2026-07-26 21:13:24 -04:00
1 parent e4cb752348
commit 7faa367408
13 files changed
+254 -23

No files matched your search

+24
View File
@@ -3,6 +3,7 @@ using allstarr.Core.Operations;
using allstarr.Core.Storage;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -145,6 +146,29 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime
Assert.Equal(0, (await _cache.PreviewMaintenanceAsync()).Media.ExpiredEntries);
}
[Fact]
public async Task MaintenanceRemovesOnlyUnreferencedAgedArtworkPayloads()
{
var referencedKey = CacheKeyBuilder.BuildMediaAssetPayloadKey(new string('a', 64));
var orphanedKey = CacheKeyBuilder.BuildMediaAssetPayloadKey(new string('b', 64));
var descriptorKey = CacheKeyBuilder.BuildMediaAssetDescriptorKey(new(
null, null, null, "jellyfin", "playlist", "playlist-1", "revision-1"));
Assert.True(await _cache.SetStringAsync(referencedKey, "referenced"));
Assert.True(await _cache.SetStringAsync(orphanedKey, "orphaned"));
Assert.True(await _cache.SetStringAsync(
descriptorKey,
JsonSerializer.Serialize(new { PayloadKey = referencedKey })));
_clock.UtcNow = _clock.UtcNow.AddMinutes(6);
var preview = await _cache.PreviewMaintenanceAsync();
Assert.Equal(1, preview.UnreferencedArtworkPayloads);
Assert.False(preview.ArtworkReferenceScanLimitReached);
Assert.Equal(1, await _cache.CleanupAsync());
Assert.Equal("referenced", await _cache.GetStringAsync(referencedKey));
Assert.Null(await _cache.GetStringAsync(orphanedKey));
}
public async Task DisposeAsync()
{
_hot.Dispose();
+28
View File
@@ -141,4 +141,32 @@ public class CacheKeyBuilderTests
ApplicationCacheCategory.NegativeResult,
ApplicationCachePolicyRegistry.Classify(key));
}
[Fact]
public void MediaDescriptorKeys_ExposeOnlyStableOwnershipDimensions()
{
var tenantId = Guid.CreateVersion7();
var userId = Guid.CreateVersion7();
var accountId = Guid.CreateVersion7();
var key = CacheKeyBuilder.BuildMediaAssetDescriptorKey(new(
tenantId,
userId,
accountId,
"spotify",
"playlist",
"private-playlist-id",
"signed-revision",
96,
96));
Assert.StartsWith(
$"media:descriptor:v2:{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}:*",
CacheKeyBuilder.BuildMediaAssetDescriptorAccountPattern(accountId));
}
}
@@ -89,18 +89,27 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
CreatedAt = _clock.UtcNow,
UpdatedAt = _clock.UtcNow
});
database.ApplicationCacheEntries.Add(new ApplicationCacheEntryRecord
{
Key = "media:descriptor:v2:global:shared:none:jellyfin:playlist:broken",
Category = ApplicationCacheCategory.CanonicalMetadata.ToString(),
Value = "{broken",
PayloadBytes = 7,
CreatedAt = _clock.UtcNow,
UpdatedAt = _clock.UtcNow
});
await database.SaveChangesAsync();
}
var preview = await _cache.PreviewMaintenanceAsync();
Assert.Equal(3, preview.ScannedEntries);
Assert.Equal(4, preview.ScannedEntries);
Assert.False(preview.ScanLimitReached);
Assert.Equal(1, preview.ExpiredEntries);
Assert.Equal(1, preview.UnknownOwnerEntries);
Assert.Equal(13, preview.ReclaimableBytes);
Assert.Equal(2, preview.UnknownOwnerEntries);
Assert.Equal(20, preview.ReclaimableBytes);
Assert.Equal(1, await _cache.CleanupExpiredAsync());
Assert.Equal(1, await _cache.CleanupInvalidOwnershipAsync());
Assert.Equal(2, await _cache.CleanupInvalidOwnershipAsync());
Assert.Equal("live", await _cache.GetStringAsync("search:live"));
await using var remaining = await _factory.CreateDbContextAsync();
+3 -2
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:v1:*"));
Assert.Single(cache.GetKeysByPattern("media:descriptor:v2:*"));
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:v1:*").Count());
Assert.Equal(2, cache.GetKeysByPattern("media:descriptor:v2:*").Count());
Assert.Single(cache.GetKeysByPattern("artwork:payload:v1:*"));
Assert.DoesNotContain(cache.GetKeysByPattern("*"), key =>
key.Contains("user-avatar-id", StringComparison.Ordinal));
@@ -96,6 +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("artwork:payload:v1:*").Count());
}
@@ -142,7 +142,10 @@ public sealed class ProviderAccountsControllerTests : IAsyncLifetime
Assert.DoesNotContain("secretReferenceFixture", JsonSerializer.Serialize(result.Value), StringComparison.Ordinal);
var cacheKey = CacheKeyBuilder.BuildProviderPlaylistDiscoveryKey(
_tenantId, _userId, account.Id, account.Revision, "spotify", null, null, 100);
var artworkKey = CacheKeyBuilder.BuildMediaAssetDescriptorKey(new(
_tenantId, _userId, account.Id, "spotify", "playlist", "private", "revision"));
await _cache.SetStringAsync(cacheKey, "{}");
await _cache.SetStringAsync(artworkKey, "{}");
await Controller(Session(_userId)).SetEnabled(
account.Id,
new ProviderAccountsController.SetProviderAccountEnabledRequest
@@ -150,6 +153,7 @@ public sealed class ProviderAccountsControllerTests : IAsyncLifetime
Enabled = false
});
Assert.False(await _cache.ExistsAsync(cacheKey));
Assert.False(await _cache.ExistsAsync(artworkKey));
await using var verification = await _factory.CreateDbContextAsync();
Assert.False((await verification.ProviderAccounts.SingleAsync(item => item.Id == account.Id)).Enabled);
}
+4 -2
View File
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.IO.Enumeration;
using System.Text.Json;
using allstarr.Services.Common;
@@ -40,8 +41,9 @@ internal sealed class TestMemoryApplicationCache : IApplicationCache
public IEnumerable<string> GetKeysByPattern(string pattern)
{
var prefix = pattern.TrimEnd('*');
return _entries.Keys.Where(key => key.StartsWith(prefix, StringComparison.Ordinal)).ToArray();
return _entries.Keys
.Where(key => FileSystemName.MatchesSimpleExpression(pattern, key))
.ToArray();
}
public Task<int> DeleteByPatternAsync(string pattern)
@@ -396,9 +396,11 @@ public sealed partial class ProviderAccountsController : ControllerBase
return Ok(AccountResponse(account, null, scope == ProviderAccountScope.User ? session.UserName : null));
}
private Task<int> InvalidateAccountCacheAsync(Guid accountId) =>
_cache.DeleteByPatternAsync(
CacheKeyBuilder.BuildProviderPlaylistDiscoveryAccountPattern(accountId));
private async Task<int> InvalidateAccountCacheAsync(Guid accountId) =>
await _cache.DeleteByPatternAsync(
CacheKeyBuilder.BuildProviderPlaylistDiscoveryAccountPattern(accountId)) +
await _cache.DeleteByPatternAsync(
CacheKeyBuilder.BuildMediaAssetDescriptorAccountPattern(accountId));
private bool TryGetSession(
out AdminAuthSession session,
@@ -41,8 +41,15 @@ public sealed record DatabaseCacheMaintenancePreview(
public sealed record ApplicationCacheMaintenancePreview(
DatabaseCacheMaintenancePreview Metadata,
FileMediaCacheMaintenancePreview Media,
int UnreferencedArtworkPayloads,
long UnreferencedArtworkBytes,
bool ArtworkReferenceScanLimitReached,
DateTimeOffset CapturedAt);
public sealed record ArtworkPayloadReferenceSnapshot(
IReadOnlySet<string> PayloadKeys,
bool ScanLimitReached);
public sealed record ApplicationCacheCategoryDiagnostics(
string Category,
string Owner,
@@ -152,6 +152,10 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable
CancellationToken cancellationToken = default) =>
_database.PreviewMaintenanceAsync(cancellationToken: cancellationToken);
public Task<ArtworkPayloadReferenceSnapshot> GetArtworkPayloadReferencesAsync(
CancellationToken cancellationToken = default) =>
_database.GetArtworkPayloadReferencesAsync(cancellationToken: cancellationToken);
public async Task<int> CleanupDatabaseAsync(CancellationToken cancellationToken = default)
{
_memory.Clear();
+14 -7
View File
@@ -67,19 +67,26 @@ public static class CacheKeyBuilder
public static string BuildMediaAssetDescriptorKey(MediaAssetIdentity identity)
{
var scope = string.Join('\u001f',
identity.TenantId?.ToString("N"),
identity.UserId?.ToString("N"),
identity.ProviderAccountId?.ToString("N"),
Normalize(identity.ProviderId),
Normalize(identity.ResourceKind),
var resource = string.Join('\u001f',
identity.ResourceId.Trim(),
identity.Revision?.Trim(),
identity.Width,
identity.Height);
return $"media:descriptor:v1:{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope)))}";
return string.Join(':',
"media",
"descriptor",
"v2",
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))));
}
public static string BuildMediaAssetDescriptorAccountPattern(Guid accountId) =>
$"media:descriptor:v2:*:*:{accountId:N}:*";
public static string BuildMediaAssetPayloadKey(string sha256) =>
$"artwork:payload:v1:{sha256}";
@@ -504,6 +504,40 @@ public sealed class DatabaseApplicationCache(
}
}
public async Task<ArtworkPayloadReferenceSnapshot> GetArtworkPayloadReferencesAsync(
int batchSize = 10_000,
CancellationToken cancellationToken = default)
{
var take = Math.Clamp(batchSize, 1, 10_000);
try
{
await using var database = await contextFactory.CreateDbContextAsync(cancellationToken);
var values = await database.Set<ApplicationCacheEntryRecord>()
.AsNoTracking()
.Where(item =>
item.Key.StartsWith("media:descriptor:") &&
(item.ExpiresAt == null || item.ExpiresAt > clock.UtcNow))
.OrderBy(item => item.Key)
.Select(item => item.Value)
.Take(take + 1)
.ToArrayAsync(cancellationToken);
var references = values.Take(take)
.Select(ReadArtworkPayloadKey)
.ToArray();
return new(
references
.Where(item => item != null)
.Cast<string>()
.ToHashSet(StringComparer.Ordinal),
values.Length > take || references.Any(item => item == null));
}
catch (Exception exception)
{
logger.LogWarning(exception, "Artwork payload reference scan failed");
return new(new HashSet<string>(StringComparer.Ordinal), true);
}
}
public async Task<int> CleanupPolicyOverflowAsync(
int batchSize = DefaultCleanupBatchSize,
CancellationToken cancellationToken = default)
@@ -633,7 +667,29 @@ public sealed class DatabaseApplicationCache(
!Enum.TryParse<ApplicationCacheCategory>(item.Category, ignoreCase: true, out var category) ||
!Enum.IsDefined(category) ||
ApplicationCachePolicyRegistry.Resolve(category).StorageTier != ApplicationCacheStorageTier.Metadata ||
ApplicationCachePolicyRegistry.Classify(item.Key) != category;
ApplicationCachePolicyRegistry.Classify(item.Key) != category ||
(item.Key.StartsWith("media:descriptor:", StringComparison.Ordinal) &&
ReadArtworkPayloadKey(item.Value) == null);
private static string? ReadArtworkPayloadKey(string value)
{
try
{
using var document = JsonDocument.Parse(value);
foreach (var property in document.RootElement.EnumerateObject())
{
if (property.Name.Equals("PayloadKey", StringComparison.OrdinalIgnoreCase))
{
return property.Value.GetString();
}
}
}
catch (JsonException)
{
}
return null;
}
private static string ToLikePattern(string pattern) =>
pattern
@@ -57,6 +57,11 @@ public sealed record FileMediaCacheMaintenancePreview(
int LastCleanupDeletedEntries,
DateTimeOffset CapturedAt);
public sealed record UnreferencedMediaPayloadPreview(
IReadOnlyList<string> Keys,
long ReclaimableBytes,
bool ScanLimitReached);
/// <summary>
/// Bounded disk cache for artwork and other reconstructable media payloads.
/// Cache keys are hashed before becoming paths and original keys live only in sidecars.
@@ -525,6 +530,54 @@ public sealed class FileMediaApplicationCache : IApplicationCache, IDisposable
}
}
public async Task<UnreferencedMediaPayloadPreview> PreviewUnreferencedArtworkAsync(
IReadOnlySet<string> referencedPayloadKeys,
int maximumEntries = 1000,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken);
try
{
var take = Math.Clamp(maximumEntries, 1, _options.MaximumCleanupFiles);
var entries = ReadAllMetadata()
.Where(item =>
item.Key.StartsWith("artwork:payload:", StringComparison.Ordinal) &&
item.LastAccessAt <= _clock.UtcNow.AddMinutes(-5) &&
!referencedPayloadKeys.Contains(item.Key))
.OrderBy(item => item.LastAccessAt)
.ThenBy(item => item.Key, StringComparer.Ordinal)
.Take(take + 1)
.ToArray();
return new(
entries.Take(take).Select(item => item.Key).ToArray(),
entries.Take(take).Sum(item => Math.Max(0, item.PayloadBytes)),
entries.Length > take);
}
finally
{
_gate.Release();
}
}
public async Task<int> CleanupUnreferencedArtworkAsync(
IReadOnlySet<string> referencedPayloadKeys,
CancellationToken cancellationToken = default)
{
var preview = await PreviewUnreferencedArtworkAsync(
referencedPayloadKeys,
cancellationToken: cancellationToken);
var deleted = 0;
foreach (var key in preview.Keys)
{
if (await DeleteAsync(key))
{
deleted++;
}
}
return deleted;
}
public void Dispose()
{
_gate.Dispose();
@@ -820,16 +873,35 @@ public sealed class HybridApplicationCache(
{
var metadataTask = metadata.PreviewDatabaseMaintenanceAsync(cancellationToken);
var mediaTask = media.PreviewCleanupAsync(cancellationToken);
await Task.WhenAll(metadataTask, mediaTask);
var referencesTask = metadata.GetArtworkPayloadReferencesAsync(cancellationToken);
await Task.WhenAll(metadataTask, mediaTask, referencesTask);
var references = await referencesTask;
var unreferenced = await media.PreviewUnreferencedArtworkAsync(
references.PayloadKeys,
cancellationToken: cancellationToken);
return new(
await metadataTask,
await mediaTask,
unreferenced.Keys.Count,
unreferenced.ReclaimableBytes,
references.ScanLimitReached || unreferenced.ScanLimitReached,
DateTimeOffset.UtcNow);
}
public async Task<int> CleanupAsync(CancellationToken cancellationToken = default) =>
await metadata.CleanupDatabaseAsync(cancellationToken) +
await media.CleanupAsync(cancellationToken);
public async Task<int> CleanupAsync(CancellationToken cancellationToken = default)
{
var deleted = await metadata.CleanupDatabaseAsync(cancellationToken);
var references = await metadata.GetArtworkPayloadReferencesAsync(cancellationToken);
deleted += await media.CleanupAsync(cancellationToken);
if (!references.ScanLimitReached)
{
deleted += await media.CleanupUnreferencedArtworkAsync(
references.PayloadKeys,
cancellationToken);
}
return deleted;
}
public async Task<int> PurgeAllAsync() =>
await PurgeMetadataAsync() + await PurgeMediaAsync();
@@ -106,6 +106,21 @@ public sealed class MediaAssetResolver(
return Asset(source, false);
var selected = CreateVariant(source, identity, maximumBytes);
if (!bytes.AsSpan().SequenceEqual(selected.Bytes))
{
var originalSha256 = Convert.ToHexStringLower(SHA256.HashData(bytes));
await cache.SetAsync(
CacheKeyBuilder.BuildMediaAssetDescriptorKey(
identity with { Width = null, Height = null }),
new MediaAssetDescriptor(
CacheKeyBuilder.BuildMediaAssetPayloadKey(originalSha256),
originalSha256,
source.ContentType,
bytes.Length,
source.ETag,
source.LastModified),
policy.FreshFor);
}
var sha256 = Convert.ToHexStringLower(SHA256.HashData(selected.Bytes));
var payloadKey = CacheKeyBuilder.BuildMediaAssetPayloadKey(sha256);
if (!bytes.AsSpan().SequenceEqual(selected.Bytes) &&