using Shared.Services.Hybrid; using System.Collections.Concurrent; namespace Music; public static class MusicMetadataCacheService { const string CacheKeyPrefix = "music:metadata:v2"; // пустой результат — чаще разовый сбой провайдера, чем правда: // полный TTL превращал его в залипшее «ничего не найдено» static readonly TimeSpan emptyTtl = TimeSpan.FromMinutes(20); sealed class PendingFill where T : class { public CancellationToken OwnerCancellation { get; init; } public Lazy> Work { get; init; } } static class PendingFills where T : class { public static readonly ConcurrentDictionary> Items = new(); } public static async Task GetOrCreateAsync( string providerId, string entityType, string cacheKey, TimeSpan ttl, Func> factory, CancellationToken cancellationToken = default, Func ttlSelector = null) where T : class { cancellationToken.ThrowIfCancellationRequested(); var cached = await GetAsync(providerId, entityType, cacheKey, cancellationToken); if (cached != null) return cached; if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey)) return await CreateAndSaveAsync(providerId, entityType, cacheKey, ttl, factory, cancellationToken, ttlSelector); string key = BuildCacheKey(providerId, entityType, cacheKey); while (true) { cancellationToken.ThrowIfCancellationRequested(); PendingFill candidate = null; candidate = new PendingFill { OwnerCancellation = cancellationToken, Work = new Lazy>(async () => { try { // A previous fill may have finished between the miss and registration. var existing = await GetAsync(providerId, entityType, cacheKey, cancellationToken); if (existing != null) return existing; return await CreateAndSaveAsync(providerId, entityType, cacheKey, ttl, factory, cancellationToken, ttlSelector); } finally { PendingFills.Items.TryRemove(new KeyValuePair>(key, candidate)); } }, LazyThreadSafetyMode.ExecutionAndPublication) }; var pending = PendingFills.Items.GetOrAdd(key, candidate); try { return await pending.Work.Value.WaitAsync(cancellationToken); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && pending.OwnerCancellation.IsCancellationRequested) { // Factories capture their caller's token. Retry with our own factory // after an abandoned owner's fill, without cancelling other waiters. } } } static async Task CreateAndSaveAsync( string providerId, string entityType, string cacheKey, TimeSpan ttl, Func> factory, CancellationToken cancellationToken, Func ttlSelector) where T : class { cancellationToken.ThrowIfCancellationRequested(); var created = await factory(); cancellationToken.ThrowIfCancellationRequested(); if (created != null) { TimeSpan selectedTtl = IsEmptyPayload(created) ? emptyTtl : ttlSelector?.Invoke(created) ?? ttl; await SaveAsync(providerId, entityType, cacheKey, created, selectedTtl, cancellationToken); } return created; } static bool IsEmptyPayload(object payload) { if (payload is System.Collections.ICollection collection) return collection.Count == 0; if (payload is MusicSearchResult search) { return (search.artists == null || search.artists.Count == 0) && (search.albums == null || search.albums.Count == 0) && (search.tracks == null || search.tracks.Count == 0); } return false; } public static async Task GetAsync(string providerId, string entityType, string cacheKey, CancellationToken cancellationToken = default) where T : class { if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey)) return null; var cache = HybridCache.Get(); // fileCache:false — иначе чтение слепо к свежим записям в tempDb-буфере // HybridCache (до флаша ~15-60s), и повторный запрос делает полный рефетч var entry = await cache.ReadCacheAsync(BuildCacheKey(providerId, entityType, cacheKey), false, null, textJson: true); return entry.succes ? entry.value : null; } public static Task SaveAsync(string providerId, string entityType, string cacheKey, T payload, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class { if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey) || payload == null) return Task.CompletedTask; _ = cancellationToken; HybridCache.Get().Set(BuildCacheKey(providerId, entityType, cacheKey), payload, ttl, textJson: true); return Task.CompletedTask; } static string BuildCacheKey(string providerId, string entityType, string cacheKey) { return string.Join(':', CacheKeyPrefix, NormalizeSegment(providerId), NormalizeSegment(entityType), NormalizeKey(cacheKey)); } static string NormalizeKey(string cacheKey) => cacheKey.Trim().ToLowerInvariant(); static string NormalizeSegment(string value) => value?.Trim().ToLowerInvariant() ?? string.Empty; }