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;
}