See More

namespace DigitalRuby.SimpleCache; ///

/// File cache item /// /// Type of item /// /// Constructor /// /// Expires /// Item /// Size on disk public sealed class FileCacheItem(DateTimeOffset expires, T item, int size) { /// /// Expiration /// public DateTimeOffset Expires { get; } = expires; /// /// Item /// public T Item { get; } = item; /// /// Size on disk /// public int Size { get; } = size; } /// /// Disk space checker /// public interface IDiskSpace { /// /// Get free space for a path /// /// Path /// Available free space /// Total space /// Free space for drive path is on double GetPercentFreeSpace(string path, out long availableFreeSpace, out long totalSpace); /// /// Get size of a file /// /// File name /// Size long GetFileSize(string fileName); } /// /// Determine free and total disk space for a given path /// public sealed class DiskSpace : IDiskSpace { /// public double GetPercentFreeSpace(string path, out long availableFreeSpace, out long totalSpace) { var info = new DriveInfo(path); availableFreeSpace = info.AvailableFreeSpace; totalSpace = info.TotalSize; return ((double)availableFreeSpace / (double)totalSpace); } /// public long GetFileSize(string fileName) => new FileInfo(fileName).Length; } /// /// Cache items using files /// public interface IFileCache { /// /// The serializer used to convert objects to bytes /// public ISerializer Serializer { get; } /// /// Time provider /// public TimeProvider Clock { get; } /// /// Get a cache value /// /// Type of value /// Key /// Cancel token /// Task of FileCacheItem of type T, null if not found /// Key is null Task?> GetAsync(string key, CancellationToken cancelToken = default); /// /// Set a cache value, overwriting any existing value /// /// Key /// Value. If the value is a byte array, it will not be serialized but will instead /// be assumed to have already been serialized. /// Cache parameters or null for default /// Cancel token /// Task /// Key is null Task SetAsync(string key, object value, CacheParameters cacheParameters = default, CancellationToken cancelToken = default); /// /// Get or create a cache item if it doesn't exist /// /// Key /// Value factory /// Cache parameters /// Cancel token /// Task of file cache item of type T /// Key is null async Task> GetOrCreateAsync(string key, Func> valueFactory, CacheParameters cacheParameters = default, CancellationToken cancelToken = default) { var result = await GetAsync(key, cancelToken); if (result is null) { var value = await valueFactory(cancelToken); if (value is not null && value is not Exception && value is not Task) { await SetAsync(key, value, cacheParameters, cancelToken); } result = new FileCacheItem(Clock.GetUtcNow() + cacheParameters.Duration, value, cacheParameters.Size); } return result; } /// /// Remove an item from the cache /// /// Key /// Cancel token /// Task /// Key is null /// Ensure the the type parameter is the exact type that you used to add the item to the cache Task RemoveAsync(string key, CancellationToken cancelToken = default); /// /// Remove all cache items /// /// Task Task ClearAsync(); } /// /// Null file cache that does nothing /// public sealed class NullFileCache : IFileCache { /// /// Serializer /// public ISerializer Serializer { get; } = new JsonLZ4Serializer(); /// /// Clock /// public TimeProvider Clock { get; } = TimeProvider.System; /// public Task?> GetAsync(string key, CancellationToken cancelToken = default) { return Task.FromResult?>(null); } /// public Task RemoveAsync(string key, CancellationToken cancelToken = default) { return Task.CompletedTask; } /// public Task SetAsync(string key, object value, CacheParameters cacheParameters = default, CancellationToken cancelToken = default) { return Task.CompletedTask; } /// public Task ClearAsync() => Task.CompletedTask; } /// /// File cache for unit tests /// /// /// Constructor /// /// Serializer /// Clock public sealed class MemoryFileCache(ISerializer serializer, TimeProvider clock) : IFileCache { private readonly ISerializer serializer = serializer; private readonly ConcurrentDictionary> items = new(); /// public ISerializer Serializer => serializer; /// /// Clock /// public TimeProvider Clock { get; } = TimeProvider.System; /// public Task?> GetAsync(string key, CancellationToken cancelToken = default) { if (items.TryGetValue(key, out var item) && item.Expires > clock.GetUtcNow()) { var value = serializer.Deserialize(item.Item); if (value is null) { return Task.FromResult?>(null); } return Task.FromResult?>(new FileCacheItem(item.Expires, value, item.Size)); } return Task.FromResult?>(null); } /// public Task RemoveAsync(string key, CancellationToken cancelToken = default) { items.TryRemove(key, out _); return Task.CompletedTask; } /// public Task SetAsync(string key, object value, CacheParameters cacheParameters = default, CancellationToken cancelToken = default) { var bytes = (value is byte[] alreadyBytes ? alreadyBytes : serializer.Serialize(value) ?? throw new IOException("Serialize failed for key " + key)); items[key] = new FileCacheItem(clock.GetUtcNow() + cacheParameters.Duration, bytes, bytes.Length); return Task.CompletedTask; } /// public Task ClearAsync() { items.Clear(); return Task.CompletedTask; } } /// public sealed class FileCache : BackgroundService, IFileCache { /// /// Clock /// public TimeProvider Clock { get; } private static readonly Type byteArrayType = typeof(byte[]); private static readonly TimeSpan cleanupLoopDelay = TimeSpan.FromMilliseconds(1.0); private readonly MultithreadedKeyLocker keyLocker = new(512); private readonly IDiskSpace diskSpace; private readonly ILogger logger; private readonly string baseDir; private readonly double freeSpaceThreshold; private bool directoryLocked; /// /// Serializer /// public ISerializer Serializer { get; } private string GetHashFileName(string key) { var hash = Blake2b.ComputeHash(16, Encoding.UTF8.GetBytes(key)); // get a base64 file name from hash bytes and fix invalid path chars var fileName = Convert.ToBase64String(hash) .Replace('/', '_') .Replace('+', '-') .Replace("=", string.Empty); // padding, can remove for shorter file name return Path.Combine(baseDir, fileName); } /// /// Constructor /// /// Options /// Serializer /// Disk space /// Clock /// Logger public FileCache(FileCacheOptions options, ISerializer serializer, IDiskSpace diskSpace, TimeProvider clock, ILogger logger) { Serializer = serializer; this.diskSpace = diskSpace; Clock = clock; this.logger = logger; string assemblyName = Assembly.GetEntryAssembly()!.GetName().Name!; baseDir = options.CacheDirectory; if (string.IsNullOrWhiteSpace(baseDir) || baseDir.IndexOfAny(Path.GetInvalidPathChars()) > 0) { throw new ArgumentException("Invalid file cache directory: " + baseDir); } else if (baseDir.Equals("%temp%", StringComparison.OrdinalIgnoreCase)) { baseDir = Path.GetTempPath(); } baseDir = Path.Combine(baseDir, assemblyName, nameof(FileCache)); Directory.CreateDirectory(baseDir); freeSpaceThreshold = (double)options.FreeSpaceThreshold * 0.01; double freePercent = diskSpace.GetPercentFreeSpace(baseDir, out long availableFreeSpace, out long totalSpace); this.logger.LogWarning("Disk space free: {freePercent:0.00}% ({availableFreeSpace}/{totalFreeSpace})", freePercent * 100.0, availableFreeSpace, totalSpace); } /// public async Task?> GetAsync(string key, CancellationToken cancelToken = default) { ArgumentNullException.ThrowIfNull(key, nameof(key)); while (directoryLocked) { await Task.Delay(cleanupLoopDelay, cancelToken); } string fileName = GetHashFileName(key); using var keyLock = keyLocker.Lock(fileName); try { if (!File.Exists(fileName)) { logger.LogDebug("File cache miss {key}, {fileName}", key, fileName); return default; } using FileStream readerStream = new(fileName, FileMode.Open, FileAccess.Read, FileShare.None); using BinaryReader reader = new(readerStream); long ticks = reader.ReadInt64(); DateTimeOffset cutOff = new(ticks, TimeSpan.Zero); if (Clock.GetUtcNow() >= cutOff) { // expired, delete, no exception for performance logger.LogDebug("File cache expired {key}, {fileName}, deleting", key, fileName); reader.Close(); try { File.Delete(fileName); } catch (Exception ex) { logger.LogError(ex, "Error removing expired file cache {key}, {fileName}", key, fileName); } return default; } else { // read item from file int size = reader.ReadInt32(); byte[] bytes = reader.ReadBytes(size); if (bytes.Length != size) { throw new IOException("Byte counts are off for file cache item"); } FileCacheItem result; if (typeof(T) == byteArrayType) { result = new FileCacheItem(new DateTimeOffset(ticks, TimeSpan.Zero), (T)(object)bytes, size); } else { var item = (T?)Serializer.Deserialize(bytes, typeof(T?)) ?? throw new IOException("Corrupt cache file " + fileName); result = new FileCacheItem(new DateTimeOffset(ticks, TimeSpan.Zero), item, size); } logger.LogDebug("File cache hit {key}, {fileName}", key, fileName); return result; } } catch (Exception ex) { // ignore, just pretend item not exists try { // clear out file logger.LogError(ex, "Error reading cache file {fileName}, deleting file", fileName); File.Delete(fileName); } catch (Exception ex2) { logger.LogError(ex2, "Error removing corrupted cache file {fileName}", fileName); } return default; } } /// public async Task RemoveAsync(string key, CancellationToken cancelToken = default) { ArgumentNullException.ThrowIfNull(key, nameof(key)); while (directoryLocked) { await Task.Delay(cleanupLoopDelay, cancelToken); } string fileName = GetHashFileName(key); using var keyLock = keyLocker.Lock(fileName); try { if (File.Exists(fileName)) { File.Delete(fileName); logger.LogDebug("File cache removed {key}, {fileName}", key, fileName); } else { logger.LogDebug("File cache remove ignored for non existing {key}, {fileName}", key, fileName); } } catch (Exception ex) { logger.LogError(ex, "Error removing existing cache file {key}, {fileName}", key, fileName); } } /// public async Task SetAsync(string key, object value, CacheParameters cacheParameters = default, CancellationToken cancelToken = default) { ArgumentNullException.ThrowIfNull(key, nameof(key)); while (directoryLocked) { await Task.Delay(cleanupLoopDelay, cancelToken); } string fileName = GetHashFileName(key); using var keyLock = keyLocker.Lock(fileName); try { using FileStream writerStream = new(fileName, FileMode.Create, FileAccess.Write, FileShare.None); using BinaryWriter writer = new(writerStream); DateTimeOffset expires = Clock.GetUtcNow() + cacheParameters.Duration; writer.Write(expires.Ticks); byte[]? bytes = (value is byte[] alreadyBytes ? alreadyBytes : Serializer.Serialize(value)); if (bytes is not null) { writer.Write(bytes.Length); writer.Write(bytes); } logger.LogDebug("File cache set {key}, {fileName}", key, fileName); } catch (Exception ex) { logger.LogError(ex, "Error setting cache file {key}, {fileName}", key, fileName); } } /// public async Task ClearAsync() { // give us 10 seconds to nuke the directory directoryLocked = true; for (int i = 0; i <= 10; i++) { try { Directory.Delete(baseDir, true); Directory.CreateDirectory(baseDir); break; } catch { await Task.Delay(1000); } } directoryLocked = false; } /// /// Clean up free space if needed /// /// Stopping token /// Task public async Task CleanupFreeSpaceAsync(CancellationToken stoppingToken = default) { // if low on disk space, purge it all while (true) { double freeSpacePercent = diskSpace.GetPercentFreeSpace(baseDir, out long availableFreeSpace, out long totalSpace); if (freeSpacePercent >= freeSpaceThreshold) { break; } bool foundFile = false; foreach (string fileName in Directory.EnumerateFiles(baseDir)) { foundFile = true; using var keyLock = keyLocker.Lock(fileName); try { // delete file and increment free space long fileSize = diskSpace.GetFileSize(fileName); File.Delete(fileName); availableFreeSpace += fileSize; // if we have freed up enough space, stop deleting files if ((double)availableFreeSpace / (double)totalSpace >= freeSpaceThreshold) { break; } } catch (Exception ex) { logger.LogError(ex, "Error cleaning up cache file {fileName}", fileName); } // don't gobble up too much cpu await Task.Delay(cleanupLoopDelay, stoppingToken); } // if no files, we are done, get out of the loop if (!foundFile) { break; } } } /// protected override async Task ExecuteAsync(CancellationToken stoppingToken = default) { // loop and continually free up space as needed while (!stoppingToken.IsCancellationRequested) { await CleanupFreeSpaceAsync(stoppingToken); await Task.Delay(10000, stoppingToken); } } } /// /// File cache options /// public sealed class FileCacheOptions { /// /// Cache directory. Default is %temp% which means to use temp directory. /// public string CacheDirectory { get; set; } = "%temp%"; /// /// Percentage (0 - 100) of free space remaining to trigger cleanup of files. Default is 15. /// public int FreeSpaceThreshold { get; set; } = 15; }