mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(cache): prune stale artwork revisions
This commit is contained in:
7 files changed
+100
-16
No files matched your search
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user