File: Caching\DiskCache.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Cli.Caching;
 
internal sealed class DiskCache : IDiskCache
{
    private static readonly TimeSpan s_defaultCacheExpiryWindow = TimeSpan.FromHours(3);
    private static readonly TimeSpan s_defaultMaxCacheAge = TimeSpan.FromDays(7);
 
    private readonly ILogger<DiskCache> _logger;
    private readonly DirectoryInfo _cacheDirectory;
    private readonly TimeSpan _expiryWindow;
    private readonly TimeSpan _maxAge;
 
    public DiskCache(ILogger<DiskCache> logger, CliExecutionContext executionContext, IConfiguration configuration)
    {
        _logger = logger;
        _cacheDirectory = new DirectoryInfo(Path.Combine(executionContext.CacheDirectory.FullName, "nuget-search"));
        if (!_cacheDirectory.Exists)
        {
            try
            {
                _cacheDirectory.Create();
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Failed to create cache directory {CacheDirectory}", _cacheDirectory.FullName);
            }
        }
        _expiryWindow = ReadWindow(configuration, "PackageSearchCacheExpirySeconds", s_defaultCacheExpiryWindow);
        _maxAge = ReadWindow(configuration, "PackageSearchMaxCacheAgeSeconds", s_defaultMaxCacheAge);
    }
 
    private static TimeSpan ReadWindow(IConfiguration configuration, string key, TimeSpan fallback)
    {
        if (configuration[key] is string secondsString && double.TryParse(secondsString, out var seconds) && seconds > 0)
        {
            return TimeSpan.FromSeconds(seconds);
        }
        return fallback;
    }
 
    public async Task<string?> GetAsync(string key, CancellationToken cancellationToken = default)
    {
        try
        {
            // The key could be any string which is not necessary suitable as a string so we convert
            // it to a hex encoded SHA256 hash of the string value.
            var keyHash = HashKey(key);
 
            // Once we hashed key we resolve the path to the cache file. The fully qualified cache
            // file will include a timestamp. As part of this call clean up of old cache entries may
            // occur to keep the cache directory clean. This may return null if there is a cache miss.
            var cacheFilePath = ResolveValidCacheFile(keyHash);
            if (cacheFilePath is null)
            {
                _logger.LogDebug("Disk cache miss for key {RawKey}", key);
                return null;
            }
 
            // Assuming here is a hit we attempt to read the file and return the string.
            _logger.LogDebug("Disk cache hit for key {RawKey} (file: {CacheFilePath})", key, cacheFilePath);
            return await File.ReadAllTextAsync(cacheFilePath, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to retrieve or read cache entry for key {RawKey}", key);
            return null;
        }
    }
 
    public async Task SetAsync(string key, string content, CancellationToken cancellationToken = default)
    {
        try
        {
            // If the cache directory doesn't exist create it, but if it blows up just return
            // failing to write the cache entry shouldn't be a fatal operation. If people notice
            // that things aren't going fast enough tey can run in debug mode and they'll see the
            // messages appearing  there.
            if (!_cacheDirectory.Exists)
            {
                try
                {
                    _cacheDirectory.Create();
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Failed to create cache directory {CacheDirectory}", _cacheDirectory.FullName);
                    return;
                }
            }
 
            // Compute the hash of the key and generate the filename based on the
            // current using timestamp. To ensure we get a clean write we create a
            // temp file to write into and then copy the file over in one step. Once
            // again - if anything blows up we just move on.
            var keyHash = HashKey(key);
            var currentUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
            var fileName = $"{keyHash}.{currentUnixTime}.json";
            var fullPath = Path.Combine(_cacheDirectory.FullName, fileName);
            var tempFile = fullPath + ".tmp";
            await File.WriteAllTextAsync(tempFile, content, cancellationToken).ConfigureAwait(false);
 
            if (File.Exists(fullPath))
            {
                try
                {
                    File.Delete(fullPath);
                }
                catch
                {
                    // Best effort; swallow per original intent.
                }
            }
 
            File.Move(tempFile, fullPath);
            _logger.LogDebug("Stored disk cache entry for key {RawKey} (file: {CacheFilePath})", key, fullPath);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to write disk cache entry for key {RawKey}", key);
        }
    }
 
    public Task ClearAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            if (_cacheDirectory.Exists)
            {
                foreach (var file in _cacheDirectory.GetFiles("*.json", SearchOption.TopDirectoryOnly))
                {
                    try
                    {
                        file.Delete();
                    }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Failed to delete cache file {CacheFile}", file.FullName);
                    }
                }
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to clear disk cache at {CacheDirectory}", _cacheDirectory.FullName);
        }
        return Task.CompletedTask;
    }
 
    private static string HashKey(string key)
    {
        var bytes = Encoding.UTF8.GetBytes(key);
        var hash = SHA256.HashData(bytes);
        return Convert.ToHexString(hash);
    }
 
    private string? ResolveValidCacheFile(string keyHash)
    {
        // The purpose of this method is to find the best cache hit (if one exists)
        // from the files that exist in the $HOME/.aspire/cache/nuget-cache directory.
        // The filename structure for cache files is <keyhash>.<unixtimestamp>.json and
        // each json file represents the JSOn payload from a single invocation from
        // an dotnet package search.
 
        // This method can absolutely be further optimized but its a huge leap up from
        // spawning a process that makes a network call :) Basically it looks at all the
        // files in the cache and then evicts the ones that are over the max age, and for
        // all the ones that match the key, it deletes the oldest ones keeping the youngest
        // assuming it is within the expiry window.
 
        try
        {
            if (!_cacheDirectory.Exists)
            {
                return null;
            }
 
            var currentUnixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
            var matchingFiles = _cacheDirectory.GetFiles($"{keyHash}.*.json");
            var allFiles = _cacheDirectory.GetFiles("*.json");
            var list = new List<(FileInfo file, long timestamp)>();
 
            foreach (var file in matchingFiles)
            {
                var nameNoExt = Path.GetFileNameWithoutExtension(file.Name);
                var parts = nameNoExt.Split('.');
                if (parts.Length >= 2 && long.TryParse(parts[1], out var ts))
                {
                    list.Add((file, ts));
                }
                else
                {
                    TryDelete(file, invalid: true);
                }
            }
 
            list.Sort((a, b) => b.timestamp.CompareTo(a.timestamp));
 
            string? valid = null;
            for (var i = 0; i < list.Count; i++)
            {
                var (file, ts) = list[i];
                var age = TimeSpan.FromSeconds(currentUnixTime - ts);
 
                if (i == 0)
                {
                    if (age <= _expiryWindow)
                    {
                        valid = file.FullName;
                    }
                    else
                    {
                        // first file exists but is expired
                        TryDelete(file, expired: true);
                    }
                }
                else
                {
                    // not the newest file; delete if it's expired (or unconditionally remove as older variants)
                    var isExpired = age > _expiryWindow;
                    TryDelete(file, expired: isExpired);
                }
            }
 
            foreach (var file in allFiles)
            {
                // Skip files already considered for this key
                if (matchingFiles.Contains(file))
                {
                    continue;
                }
 
                var nameNoExt = Path.GetFileNameWithoutExtension(file.Name);
                var parts = nameNoExt.Split('.');
 
                if (parts.Length >= 2 && long.TryParse(parts[1], out var ts))
                {
                    var age = TimeSpan.FromSeconds(currentUnixTime - ts);
                    if (age > _maxAge)
                    {
                        TryDelete(file, old: true);
                    }
                }
                else
                {
                    TryDelete(file, invalid: true);
                }
            }
 
            return valid;
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to resolve cache file for key hash {KeyHash}", keyHash);
            return null;
        }
    }
 
    private void TryDelete(FileInfo file, bool expired = false, bool old = false, bool invalid = false)
    {
        try
        {
            file.Delete();
            if (expired)
            {
                _logger.LogDebug("Deleted expired cache file: {CacheFile}", file.FullName);
            }
            else if (old)
            {
                _logger.LogDebug("Deleted old cache file during global cleanup: {CacheFile}", file.FullName);
            }
            else if (invalid)
            {
                _logger.LogDebug("Deleted invalid cache file: {CacheFile}", file.FullName);
            }
            else
            {
                _logger.LogDebug("Deleted cache file: {CacheFile}", file.FullName);
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to delete cache file {CacheFile}", file.FullName);
        }
    }
}