mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(cache): clean account-scoped artwork
This commit is contained in:
13 files changed
+254
-23
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
Reference in new issue
Block a user