diff --git a/extensions/Redis/CustomRedisCacheService.cs b/extensions/Redis/CustomRedisCacheService.cs index 8b0a923..6f6aab2 100644 --- a/extensions/Redis/CustomRedisCacheService.cs +++ b/extensions/Redis/CustomRedisCacheService.cs @@ -6,8 +6,8 @@ namespace SimpleCDN.Extensions.Redis { - public sealed class CustomRedisCacheService(IOptionsMonitor options, IOptionsMonitor cacheOptions) - : IDistributedCache, IAsyncDisposable, IDisposable + internal sealed class CustomRedisCacheService(IOptionsMonitor options, IOptionsMonitor cacheOptions) + : IRedisCacheService, IAsyncDisposable, IDisposable { private readonly IOptionsMonitor options = options; private readonly IOptionsMonitor cacheOptions = cacheOptions; diff --git a/extensions/Redis/IRedisCacheService.cs b/extensions/Redis/IRedisCacheService.cs new file mode 100644 index 0000000..e01c9bc --- /dev/null +++ b/extensions/Redis/IRedisCacheService.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Caching.Distributed; +using StackExchange.Redis; + +namespace SimpleCDN.Extensions.Redis +{ + public interface IRedisCacheService : IDistributedCache + { + public ConnectionMultiplexer GetRedisConnection(); + public Task GetRedisConnectionAsync(); + } +} diff --git a/extensions/Redis/SimpleCDNBuilderExtensions.cs b/extensions/Redis/SimpleCDNBuilderExtensions.cs index 02518b3..a0eb10f 100644 --- a/extensions/Redis/SimpleCDNBuilderExtensions.cs +++ b/extensions/Redis/SimpleCDNBuilderExtensions.cs @@ -18,15 +18,13 @@ public static class SimpleCDNBuilderExtensions /// public static ISimpleCDNBuilder AddRedisCache(this ISimpleCDNBuilder builder, Action configure) { - builder.Services.AddSingleton(); - - builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); builder.Services.AddOptionsWithValidateOnStart() .Configure(configure) .Validate>((config, logger) => config.Validate(logger), InvalidConfigurationMessage); - builder.UseCacheImplementation(); + builder.UseCacheImplementation(); return builder; } diff --git a/src/core/Configuration/ConfigurationExtensions.cs b/src/core/Configuration/CDNBuilderExtensions.cs similarity index 63% rename from src/core/Configuration/ConfigurationExtensions.cs rename to src/core/Configuration/CDNBuilderExtensions.cs index 21754f7..f2e5335 100644 --- a/src/core/Configuration/ConfigurationExtensions.cs +++ b/src/core/Configuration/CDNBuilderExtensions.cs @@ -1,4 +1,6 @@ -using Microsoft.Extensions.Caching.Distributed; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection.Extensions; using SimpleCDN.Services; using SimpleCDN.Services.Caching; using SimpleCDN.Services.Caching.Implementations; @@ -6,13 +8,14 @@ using SimpleCDN.Services.Compression.Implementations; using SimpleCDN.Services.Implementations; using System.ComponentModel; +using System.Text.Json; namespace SimpleCDN.Configuration { /// /// Provides extension methods for configuring SimpleCDN. /// - public static class ConfigurationExtensions + public static class CDNBuilderExtensions { const string InvalidConfigurationMessage = "See the log meessages for details."; @@ -53,15 +56,13 @@ public static ISimpleCDNBuilder AddSimpleCDN(this IServiceCollection services) private class SimpleCDNBuilder : ISimpleCDNBuilder { - private Type _cacheImplementationType = typeof(DisabledCache); - public SimpleCDNBuilder(IServiceCollection services) { Services = services; Services.AddSingleton(); Services.AddSingleton(); - Services.AddSingleton(sp => new CacheImplementationResolver(sp, _cacheImplementationType)); + UseCacheImplementation(); Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(SourceGenerationContext.Default)); } @@ -79,29 +80,33 @@ public ISimpleCDNBuilder ConfigureCaching(Action configure) return this; } + public ISimpleCDNBuilder DisableCaching() => UseCacheImplementation(); + + private void RemoveCacheImplementations() + { + Services.RemoveAll(); + } + public ISimpleCDNBuilder UseCacheImplementation() where TImplementation : IDistributedCache { - /* - * - if the service is registered as IDistributedCache - * - and - * | the implementation type is the specified type - * | or the implementation instance is the specified type - * | or the implementation factory is not null (we can't check the type) - * - * then we can use the specified cache implementation. - */ - if (!Services.Any(s => s.ServiceType == typeof(IDistributedCache) - && (s.ImplementationType == typeof(TImplementation) - || s.ImplementationInstance?.GetType() == typeof(TImplementation) - || s.ImplementationFactory is not null))) - { - throw new InvalidOperationException("The specified cache implementation is not registered."); - } - - _cacheImplementationType = typeof(TImplementation); + RemoveCacheImplementations(); + + Services.AddSingleton(sp => new CacheImplementationResolver(sp, typeof(TImplementation))); + return this; + } + + public ISimpleCDNBuilder UseCacheImplementation(Func resolve) where TImplementation : IDistributedCache + { + RemoveCacheImplementations(); + Services.AddSingleton(sp => new CacheImplementationResolver(resolve(sp))); return this; } + + public ISimpleCDNBuilder UseCacheImplementation(TImplementation implementation) where TImplementation : IDistributedCache + { + return UseCacheImplementation(_ => implementation); + } } } @@ -125,11 +130,33 @@ public interface ISimpleCDNBuilder /// ISimpleCDNBuilder ConfigureCaching(Action configure); + /// + /// Disables caching. This may be overridden again when you register a cache implementation afterwards. + /// + ISimpleCDNBuilder DisableCaching(); + /// /// Configures SimpleCDN to use the specified cache implementation. This should be used when a custom cache implementation is used. /// + /// + /// The serivce type to use as the cache implementation. Must implement . + /// [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] ISimpleCDNBuilder UseCacheImplementation() where TImplementation : IDistributedCache; + + /// + /// Configures SimpleCDN to use the specified cache implementation. This should be used when a custom cache implementation is used. + /// + [Browsable(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + ISimpleCDNBuilder UseCacheImplementation(Func provider) where TImplementation : IDistributedCache; + + /// + /// Configures SimpleCDN to use the specified cache implementation. This should be used when a custom cache implementation is used. + /// + [Browsable(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + ISimpleCDNBuilder UseCacheImplementation(TImplementation implementation) where TImplementation : IDistributedCache; } } diff --git a/src/core/Configuration/InMemoryCacheConfiguration.cs b/src/core/Configuration/InMemoryCacheConfiguration.cs index 1e930ee..4b63db5 100644 --- a/src/core/Configuration/InMemoryCacheConfiguration.cs +++ b/src/core/Configuration/InMemoryCacheConfiguration.cs @@ -11,9 +11,13 @@ public class InMemoryCacheConfiguration public uint MaxSize { get; set; } = 500_000; // 500 MB /// + /// This value is not used anymore, and will be removed in a future version. + /// The cache doesn't work with purging intervals anymore, and instead directly waits for the oldest item to expire. + ///

/// The interval at which the cache is purged of expired items in Minutes. Default is 5 minutes. /// Set to 0 to disable automatic purging. ///
+ [Obsolete("The cache doesn't work with purging intervals anymore. This value is not used anymore, and will be removed in a future version.")] public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromMinutes(5); // 5 minutes } } diff --git a/src/core/Configuration/InMemoryCacheConfigurationExtensions.cs b/src/core/Configuration/InMemoryCacheConfigurationExtensions.cs index f1f4dad..f291a95 100644 --- a/src/core/Configuration/InMemoryCacheConfigurationExtensions.cs +++ b/src/core/Configuration/InMemoryCacheConfigurationExtensions.cs @@ -16,9 +16,7 @@ public static ISimpleCDNBuilder AddInMemoryCache(this ISimpleCDNBuilder builder, if (configure is not null) builder.Services.Configure(configure); - builder.Services.AddSingleton() - .AddHostedService(sp => sp.GetRequiredService()) - .AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); builder.UseCacheImplementation(); return builder; } diff --git a/src/core/GlobalConstants.cs b/src/core/GlobalConstants.cs index ba77569..aaabb02 100644 --- a/src/core/GlobalConstants.cs +++ b/src/core/GlobalConstants.cs @@ -1,8 +1,10 @@ /* - * This file combines AssemblyInfo.cs and GlobalSuppressions.cs into a single file, as they are both used to configure the assembly. - * In here, we also have the Globals class for constant Assembly-wide values. + * This file combines AssemblyInfo.cs and GlobalSuppressions.cs into a single file, + * as they are both used to configure things for the whole assembly. + * In here, we also have the Globals class for constant Assembly-wide values, and the SourceGenerationContext for JSON serialization. */ +using Microsoft.Extensions.Diagnostics.HealthChecks; using SimpleCDN.Services.Caching.Implementations; using SimpleCDN.Services.Implementations; using System.Runtime.CompilerServices; @@ -25,11 +27,14 @@ internal static class GlobalConstants // NOTE: when changing this value, make sure to also change the references in // other places like the Dockerfile health check (use the global search feature of your IDE!) // but ultimately, this value should never change. + // This value should not start nor end with a slash. /// /// The root URL for the system files, like the style sheet. /// public const string SystemFilesRelativePath = "_cdn"; + // This value should only contain letters, numbers, and dashes. + // But if you need to change it, you're probably doing something wrong. /// /// The key of the item that contains the CDN route. /// This is used by the to determine the base URL the CDN is placed at. @@ -37,12 +42,13 @@ internal static class GlobalConstants public const string CDNRouteValueKey = "cdnRoute"; } - [JsonSourceGenerationOptions] - [JsonSerializable(typeof(SizeLimitedCacheDebugView))] + [JsonSourceGenerationOptions(Converters = [typeof(JsonStringEnumConverter)])] #if DEBUG // only generate serializers for debug views in debug mode + [JsonSerializable(typeof(SizeLimitedCacheDebugView))] [JsonSerializable(typeof(BasicDebugView))] [JsonSerializable(typeof(DebugView))] [JsonSerializable(typeof(DetailedDebugView))] #endif + [JsonSerializable(typeof(object))] internal partial class SourceGenerationContext : JsonSerializerContext; } diff --git a/src/core/Helpers/InternalExtensions.cs b/src/core/Helpers/InternalExtensions.cs index 0669ac2..8f50b41 100644 --- a/src/core/Helpers/InternalExtensions.cs +++ b/src/core/Helpers/InternalExtensions.cs @@ -2,36 +2,29 @@ namespace SimpleCDN.Helpers { - internal static partial class InternalExtensions + internal static class InternalExtensions { - static readonly string[] sizeNames = ["", "k", "M", "G", "T"]; + // only up to exabytes, because 1 yottabyte doesn't even fit in a ulong + // (I also don't think we are going to need it anytime soon) + static readonly string[] sizeNames = ["k", "M", "G", "T", "P", "E"]; public static string FormatByteCount(this long number) { - var isNegative = false; - if (number < 0) + if (number is < 1000 and > -1000) { - isNegative = true; - number = -number; + return number + "B"; } - var sizeNameIndex = 0; + int sizeNameIndex = 0; + double result = double.Abs(number); - double result = number; - - for (; sizeNameIndex < sizeNames.Length - 1; sizeNameIndex++) + while (result >= 1000 && sizeNameIndex < sizeNames.Length - 1) { - var div = result / 1000; - if (div < 1) - break; - - result = div; + result /= 1000; + sizeNameIndex++; } - if (isNegative) - result = -result; - - return $"{result:0.##}{sizeNames[sizeNameIndex]}B"; + return $"{(number < 0 ? "-": "")}{result:0.##}{sizeNames[sizeNameIndex]}B"; } /// @@ -144,22 +137,6 @@ public static string ForLog(this ReadOnlySpan input) return builder.ToString(); } - public static bool HasAnyFlag(this T enumValue, T flag) where T : Enum, IConvertible - { - var intValue = enumValue.ToInt64(null); - var intFlag = flag.ToInt64(null); - - return (intValue & intFlag) != 0; - } - - public static bool DoesNotHaveFlag(this T enumValue, T flag) where T : Enum, IConvertible - { - var intValue = enumValue.ToInt64(null); - var intFlag = flag.ToInt64(null); - - return (intValue & intFlag) == 0; - } - public static void RemoveWhere(this IDictionary dictionary, Func, bool> predicate) { foreach ((TKey key, _) in dictionary.Where(predicate).ToList()) diff --git a/src/core/Services/Caching/ICacheDebugInfoProvider.cs b/src/core/Services/Caching/ICacheDebugInfoProvider.cs index ebedd57..e9325d7 100644 --- a/src/core/Services/Caching/ICacheDebugInfoProvider.cs +++ b/src/core/Services/Caching/ICacheDebugInfoProvider.cs @@ -1,7 +1,12 @@ namespace SimpleCDN.Services.Caching { +#if DEBUG + /// + /// Provides debug information about the cache implementation. + /// internal interface ICacheDebugInfoProvider { internal object GetDebugInfo(); } +#endif } diff --git a/src/core/Services/Caching/Implementations/CacheImplementationResolver.cs b/src/core/Services/Caching/Implementations/CacheImplementationResolver.cs index 6753890..2c94fb2 100644 --- a/src/core/Services/Caching/Implementations/CacheImplementationResolver.cs +++ b/src/core/Services/Caching/Implementations/CacheImplementationResolver.cs @@ -5,20 +5,23 @@ namespace SimpleCDN.Services.Caching.Implementations /// /// Resolves the selected cache implementation from the registered services. /// - internal class CacheImplementationResolver(IServiceProvider services, Type implementationType) : ICacheImplementationResolver + internal class CacheImplementationResolver : ICacheImplementationResolver { - private IDistributedCache? _impl; - public IDistributedCache Implementation + public CacheImplementationResolver(IServiceProvider services, Type implementationType) { - get - { - _impl ??= services.GetServices().FirstOrDefault(s => s.GetType() == implementationType); - - if (_impl is null) - throw new InvalidOperationException($"The specified cache implementation ({implementationType.Name}) is not registered."); + var resolved = services.GetService(implementationType) + ?? services.GetServices().FirstOrDefault(s => s.GetType() == implementationType); - return _impl; + if (resolved is not IDistributedCache dc) + { + throw new InvalidOperationException($"The specified cache implementation type '{implementationType}' is not registered."); } + + Implementation = dc; } + + public CacheImplementationResolver(IDistributedCache implementation) => Implementation = implementation; + + public IDistributedCache Implementation { get; } } } diff --git a/src/core/Services/Caching/Implementations/InMemoryCache.Compacting.cs b/src/core/Services/Caching/Implementations/InMemoryCache.Compacting.cs new file mode 100644 index 0000000..861cdfa --- /dev/null +++ b/src/core/Services/Caching/Implementations/InMemoryCache.Compacting.cs @@ -0,0 +1,64 @@ +using SimpleCDN.Helpers; + +namespace SimpleCDN.Services.Caching.Implementations +{ + partial class InMemoryCache + { + private bool _newItemsAdded = false; + private Task? _compactingTask; + + private readonly CancellationTokenSource _compactingCTS = new(); + + private void DisposeCompacting() + { + _compactingCTS.Cancel(); + // Wait 10 ms for the task to finish gracefully + _compactingTask?.Wait(10); + _compactingCTS.Dispose(); + } + + /// + /// Notifies the background task that there may be work to do. + /// + private void Compact() + { + _newItemsAdded = true; + if (_compactingTask is not { IsCompleted: false }) + { + _compactingTask?.Dispose(); + _compactingTask = Task.Run(CompactBackgroundTask); + } + } + + /// + /// Removes the oldest (least recently accessed) items from the cache until the size is within the limit. + /// Do not use this method directly, use instead. + /// + void CompactBackgroundTask() + { + while (_newItemsAdded && !_compactingCTS.IsCancellationRequested && Size > MaxSize) + { + _newItemsAdded = false; + + using IEnumerator> byOldestEnumerator = + _dictionary + .OrderBy(wrapper => wrapper.Value.AccessedAt).GetEnumerator(); + + // remove the oldest items until the size is within the limit + while (!_compactingCTS.IsCancellationRequested && Size > MaxSize) + { + try + { + byOldestEnumerator.MoveNext(); + _dictionary.TryRemove(byOldestEnumerator.Current.Key, out _); + } catch (ArgumentOutOfRangeException) + { + // code should never reach this point, but just in case as a safety net + _logger.LogWarning("Cache size exceeded the limit, but no more items could be removed to bring it back within the limit."); + break; + } + } + } + } + } +} diff --git a/src/core/Services/Caching/Implementations/InMemoryCache.Debug.cs b/src/core/Services/Caching/Implementations/InMemoryCache.Debug.cs new file mode 100644 index 0000000..cf8956f --- /dev/null +++ b/src/core/Services/Caching/Implementations/InMemoryCache.Debug.cs @@ -0,0 +1,21 @@ +namespace SimpleCDN.Services.Caching.Implementations +{ + // this partial class contains the debug information for the InMemoryCache, + // and is only compiled in DEBUG mode +#if DEBUG + partial class InMemoryCache : ICacheDebugInfoProvider + { + internal void Clear() + { + _dictionary.Clear(); + } + + public object GetDebugInfo() + { + return new SizeLimitedCacheDebugView(Size, MaxSize, Count, [.. Keys], FillPercentage: (double)Size / MaxSize); + } + } + + internal record SizeLimitedCacheDebugView(long Size, long MaxSize, int Count, string[] Keys, double FillPercentage); +#endif +} diff --git a/src/core/Services/Caching/Implementations/InMemoryCache.Purging.cs b/src/core/Services/Caching/Implementations/InMemoryCache.Purging.cs new file mode 100644 index 0000000..8bbff2d --- /dev/null +++ b/src/core/Services/Caching/Implementations/InMemoryCache.Purging.cs @@ -0,0 +1,148 @@ +using SimpleCDN.Helpers; +using System.Diagnostics; +using System.IO.Pipes; + +namespace SimpleCDN.Services.Caching.Implementations +{ + #region old + // internal partial class InMemoryCache : IHostedService + //{ + // #region Automated Purging + // private CancellationTokenSource? _backgroundCTS; + // private IDisposable? _optionsOnChange; + + // /// + // /// Notifies the cache that there are new items that may need to be purged. + // /// + // private void Purge() + // { + // _dictionary.RemoveWhere(kvp => Stopwatch.GetElapsedTime(kvp.Value.AccessedAt) < _cacheOptions.CurrentValue.MaxAge); + // } + + // private async Task PurgeLoop() + // { + // while (_backgroundCTS?.Token.IsCancellationRequested is false) + // { + // await Task.Delay(_options.CurrentValue.PurgeInterval, _backgroundCTS.Token); + + // if (_backgroundCTS.Token.IsCancellationRequested) + // break; + + // _logger.LogDebug("Purging expired cache items"); + + // Purge(); + // } + // } + + // public Task StartAsync(CancellationToken cancellationToken) + // { + // // register the options change event to restart the background task + // _optionsOnChange ??= _options.OnChange((_,_) => StartAsync(default)); + + // if (_cacheOptions.CurrentValue.MaxAge == TimeSpan.Zero || _options.CurrentValue.PurgeInterval == TimeSpan.Zero) + // { + // // automatic expiration and purging are disabled. + // // stop the background task if it's running and return + // _backgroundCTS?.Dispose(); + // _backgroundCTS = null; + // return Task.CompletedTask; + // } + + // if (_backgroundCTS is not null) + // { + // // background task is already running, no need to start another + // return Task.CompletedTask; + // } + + // _backgroundCTS = new CancellationTokenSource(); + + // _backgroundCTS.Token.Register(() => + // { + // // if the token is cancelled, dispose the token source and set it to null + // // so that the next time StartAsync is called it may be recreated + // _backgroundCTS?.Dispose(); + // _backgroundCTS = null; + // }); + + // // The background task will run in the background until the token is cancelled + // // execution of the current method will continue immediately + // Task.Run(PurgeLoop, + // CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _backgroundCTS.Token).Token); + + // return Task.CompletedTask; + // } + + // public Task StopAsync(CancellationToken cancellationToken) + // { + // _optionsOnChange?.Dispose(); + // return _backgroundCTS?.CancelAsync() ?? Task.CompletedTask; + // } + // #endregion + //} + #endregion + + partial class InMemoryCache + { + private CancellationTokenSource? _purgeCTS; + + private Task? _purgeTask; + + /// + /// Notifies the cache that there are new items that may need to be purged in the future. + /// + private void Purge() + { + if (_purgeTask is not { IsCompleted: false }) + { + // of the task is not running anymore or the token is cancelled, restart the task + _purgeCTS?.Cancel(); + _purgeCTS?.Dispose(); + _purgeTask?.Dispose(); + _purgeCTS = new CancellationTokenSource(); + _purgeTask = Task.Run(PurgeLoop, _purgeCTS.Token); + } + } + + private async Task PurgeLoop() + { + while (_purgeCTS is { IsCancellationRequested: false }) + { + // if there are no items in the cache, there is no need to purge + // so we stop the background task until there are new items. + // This is to save resources and prevent unnecessary loops and checks. + if (_dictionary.IsEmpty) + { + return; + } + + TimeSpan oldestAge = Stopwatch.GetElapsedTime(_dictionary.MinBy(kvp => kvp.Value.AccessedAt).Value.AccessedAt); + + if (oldestAge > _cacheOptions.CurrentValue.MaxAge) + { + ActualPurge(); + continue; + } + + TimeSpan timeUntilNextPurge = _cacheOptions.CurrentValue.MaxAge - oldestAge; + + await Task.Delay(timeUntilNextPurge, _purgeCTS.Token); + + if (_purgeCTS.Token.IsCancellationRequested) + break; + + ActualPurge(); + } + } + + private void ActualPurge() + { + var now = Stopwatch.GetTimestamp(); + _dictionary.RemoveWhere(kvp => Stopwatch.GetElapsedTime(kvp.Value.AccessedAt, now) > _cacheOptions.CurrentValue.MaxAge); + } + + private void DisposePurging() + { + _purgeTask?.Dispose(); + } + } +} diff --git a/src/core/Services/Caching/Implementations/InMemoryCache.cs b/src/core/Services/Caching/Implementations/InMemoryCache.cs index abe06d2..e239191 100644 --- a/src/core/Services/Caching/Implementations/InMemoryCache.cs +++ b/src/core/Services/Caching/Implementations/InMemoryCache.cs @@ -11,8 +11,8 @@ namespace SimpleCDN.Services.Caching.Implementations /// A local, in-memory cache that limits the total size of the stored values. When the size of the cache exceeds the specified limit, the oldest (least recently accessed) values are removed.
/// Implements for compatibility with the . It is not actually distributed. ///
- internal class InMemoryCache(IOptionsMonitor options, IOptionsMonitor cacheOptions, ILogger logger) - : IDistributedCache, IHostedService, ICacheDebugInfoProvider + internal partial class InMemoryCache(IOptionsMonitor options, IOptionsMonitor cacheOptions, ILogger logger) + : IDistributedCache, IDisposable { private readonly IOptionsMonitor _options = options; private readonly IOptionsMonitor _cacheOptions = cacheOptions; @@ -41,8 +41,16 @@ internal class InMemoryCache(IOptionsMonitor options return token.IsCancellationRequested ? Task.FromCanceled(token) : Task.FromResult(Get(key)); } - public void Refresh(string key) { } - public Task RefreshAsync(string key, CancellationToken token = default) => token.IsCancellationRequested ? Task.FromCanceled(token) : Task.CompletedTask; + public void Refresh(string key) + { + if (_dictionary.TryGetValue(key, out ValueWrapper? wrapper)) + { + wrapper.Refresh(); + } + } + + public Task RefreshAsync(string key, CancellationToken token = default) + => token.IsCancellationRequested ? Task.FromCanceled(token) : Task.CompletedTask; public void Remove(string key) => _dictionary.TryRemove(key, out _); public Task RemoveAsync(string key, CancellationToken token = default) { @@ -56,23 +64,8 @@ public Task RemoveAsync(string key, CancellationToken token = default) public void Set(string key, byte[] value, DistributedCacheEntryOptions options) { _dictionary[key] = new ValueWrapper(value); - - IEnumerable> byOldest = _dictionary.OrderBy(wrapper => wrapper.Value.AccessedAt).AsEnumerable(); - - // remove the oldest items until the size is within the limit - while (Size > MaxSize) - { - try - { - ((var oldestKey, _), byOldest) = byOldest.RemoveFirst(); - _dictionary.TryRemove(oldestKey, out _); - } catch (ArgumentOutOfRangeException) - { - // code should never reach this point, but just in case as a safety net - _logger.LogWarning("Cache size exceeded the limit, but no more items could be removed to bring it back within the limit."); - break; - } - } + Compact(); + Purge(); } public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) @@ -84,83 +77,10 @@ public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions opti return Task.CompletedTask; } - internal void Clear() - { - _dictionary.Clear(); - } - - #region Automated Purging - private CancellationTokenSource? _backgroundCTS; - private IDisposable? _optionsOnChange; - - private void Purge() - { - _dictionary.RemoveWhere(kvp => Stopwatch.GetElapsedTime(kvp.Value.AccessedAt) < _cacheOptions.CurrentValue.MaxAge); - } - - private async Task PurgeLoop() - { - while (_backgroundCTS?.Token.IsCancellationRequested is false) - { - await Task.Delay(_options.CurrentValue.PurgeInterval, _backgroundCTS.Token); - - if (_backgroundCTS.Token.IsCancellationRequested) - break; - - _logger.LogDebug("Purging expired cache items"); - - Purge(); - } - } - - public Task StartAsync(CancellationToken cancellationToken) - { - // register the options change event to restart the background task - _optionsOnChange ??= _options.OnChange(_ => StartAsync(default)); - - if (_cacheOptions.CurrentValue.MaxAge == TimeSpan.Zero || _options.CurrentValue.PurgeInterval == TimeSpan.Zero) - { - // automatic expiration and purging are disabled. - // stop the background task if it's running and return - _backgroundCTS?.Dispose(); - _backgroundCTS = null; - return Task.CompletedTask; - } - - if (_backgroundCTS is not null) - { - // background task is already running, no need to start another - return Task.CompletedTask; - } - - _backgroundCTS = new CancellationTokenSource(); - - _backgroundCTS.Token.Register(() => - { - // if the token is cancelled, dispose the token source and set it to null - // so that the next time StartAsync is called it may be recreated - _backgroundCTS?.Dispose(); - _backgroundCTS = null; - }); - - // The background task will run in the background until the token is cancelled - // execution of the current method will continue immediately - Task.Run(PurgeLoop, - CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _backgroundCTS.Token).Token); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _optionsOnChange?.Dispose(); - return _backgroundCTS?.CancelAsync() ?? Task.CompletedTask; - } - #endregion - - public object GetDebugInfo() + public void Dispose() { - return new SizeLimitedCacheDebugView(Size, MaxSize, Count, [.. Keys], FillPercentage: (double)Size / MaxSize); + DisposeCompacting(); + DisposePurging(); } class ValueWrapper(byte[] value) @@ -168,7 +88,7 @@ class ValueWrapper(byte[] value) private byte[] _value = value; /// - /// The value stored in the cache. Accessing this property will update the last accessed timestamp. + /// The value stored in the cache. Accessing this property will update to the current time. /// public byte[] Value { @@ -183,11 +103,13 @@ public byte[] Value AccessedAt = Stopwatch.GetTimestamp(); } } + + public void Refresh() => AccessedAt = Stopwatch.GetTimestamp(); + public int Size => _value.Length; public long AccessedAt { get; private set; } = Stopwatch.GetTimestamp(); public static implicit operator byte[](ValueWrapper wrapper) => wrapper.Value; } } - internal record SizeLimitedCacheDebugView(long Size, long MaxSize, int Count, string[] Keys, double FillPercentage); } diff --git a/src/standalone/AdditionalEndpoints.cs b/src/standalone/AdditionalEndpoints.cs index b04ce61..cf2b697 100644 --- a/src/standalone/AdditionalEndpoints.cs +++ b/src/standalone/AdditionalEndpoints.cs @@ -52,12 +52,8 @@ private static WebApplication MapHealthChecks(this WebApplication app) { ResponseWriter = async (ctx, health) => { - JsonOptions jsonOptions = ctx.RequestServices.GetRequiredService>().Value; ctx.Response.ContentType = MediaTypeNames.Application.Json; -#pragma warning disable IL2026, IL3050 // it thinks it requires unreferenced code, - // but the TypeInfoResolverChain actually provides the necessary context - await ctx.Response.WriteAsJsonAsync(health); -#pragma warning restore IL2026, IL3050 + await ctx.Response.WriteAsJsonAsync(CustomHealthReport.FromHealthReport(health), ExtraSourceGenerationContext.Default.CustomHealthReport); } }); diff --git a/src/standalone/ApplicationBuilderExtensions.cs b/src/standalone/ApplicationBuilderExtensions.cs index 9aea0cb..8463504 100644 --- a/src/standalone/ApplicationBuilderExtensions.cs +++ b/src/standalone/ApplicationBuilderExtensions.cs @@ -33,7 +33,7 @@ internal static ISimpleCDNBuilder MapConfiguration(this ISimpleCDNBuilder builde } }); builder.Services.AddHealthChecks() - .AddRedis(sp => sp.GetRequiredService().GetRedisConnection(), "Redis"); + .AddRedis(sp => sp.GetRequiredService().GetRedisConnection(), "Redis"); break; case CacheType.InMemory: builder.AddInMemoryCache(config => diff --git a/src/standalone/CustomHealthReport.cs b/src/standalone/CustomHealthReport.cs new file mode 100644 index 0000000..66b8289 --- /dev/null +++ b/src/standalone/CustomHealthReport.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace SimpleCDN.Standalone +{ + public class CustomHealthReport + { + public HealthStatus Status { get; init; } + public TimeSpan TotalDuration { get; init; } + public IReadOnlyDictionary Entries { get; init; } = new Dictionary(); + + public static CustomHealthReport FromHealthReport(HealthReport report) + { + return new CustomHealthReport + { + Entries = report.Entries.ToDictionary( + kvp => kvp.Key, + kvp => CustomHealthReportEntry.FromHealthReportEntry(kvp.Value) + ), + Status = report.Status, + TotalDuration = report.TotalDuration + }; + } + + public readonly struct CustomHealthReportEntry + { + public readonly HealthStatus Status { get; init; } + public readonly string? Description { get; init; } + public readonly TimeSpan Duration { get; init; } + public readonly IReadOnlyDictionary Data { get; init; } + public readonly IEnumerable Tags { get; init; } + public readonly bool HasException { get; init; } + + public static CustomHealthReportEntry FromHealthReportEntry(HealthReportEntry entry) + { + return new CustomHealthReportEntry + { + Status = entry.Status, + Description = entry.Description, + Duration = entry.Duration, + Data = entry.Data, + Tags = entry.Tags, + HasException = entry.Exception != null + }; + } + } + } +} diff --git a/src/standalone/GlobalConstants.cs b/src/standalone/GlobalConstants.cs index c4cee75..b98b092 100644 --- a/src/standalone/GlobalConstants.cs +++ b/src/standalone/GlobalConstants.cs @@ -1,18 +1,20 @@ /* * This file is basically AssemblyInfo.cs, but with the option to add global suppressions, - * or global constants. + * or globally accessed classes, constants, etc. */ using Microsoft.Extensions.Diagnostics.HealthChecks; using SimpleCDN.Configuration; using SimpleCDN.Extensions.Redis; +using SimpleCDN.Standalone; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; [assembly: InternalsVisibleTo("SimpleCDN.Tests.Integration")] +[JsonSourceGenerationOptions(Converters = [typeof(JsonStringEnumConverter)])] [JsonSerializable(typeof(CacheConfiguration))] [JsonSerializable(typeof(CDNConfiguration))] [JsonSerializable(typeof(RedisCacheConfiguration))] [JsonSerializable(typeof(InMemoryCacheConfiguration))] -[JsonSerializable(typeof(HealthReport))] +[JsonSerializable(typeof(CustomHealthReport))] internal partial class ExtraSourceGenerationContext : JsonSerializerContext; diff --git a/src/standalone/Program.cs b/src/standalone/Program.cs index 62fcb88..ae0f861 100644 --- a/src/standalone/Program.cs +++ b/src/standalone/Program.cs @@ -31,11 +31,8 @@ private static void Main(string[] args) builder.Services.AddSimpleCDN() .MapConfiguration(builder.Configuration); - builder.Services.ConfigureHttpJsonOptions(options => - { - options.SerializerOptions.TypeInfoResolverChain.Add(ExtraSourceGenerationContext.Default); - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); + builder.Services.ConfigureHttpJsonOptions(options + => options.SerializerOptions.TypeInfoResolverChain.Add(ExtraSourceGenerationContext.Default)); builder.Services.AddHealthChecks() .AddCheck("Self", () => HealthCheckResult.Healthy()); diff --git a/tests/unit/ByteCountFormatterTests.cs b/tests/unit/ByteCountFormatterTests.cs index 2918161..644af78 100644 --- a/tests/unit/ByteCountFormatterTests.cs +++ b/tests/unit/ByteCountFormatterTests.cs @@ -14,8 +14,10 @@ public class ByteCountFormatterTests [TestCase(1_000_000_000_000, "1TB")] [TestCase(-1, "-1B")] [TestCase(-1000, "-1kB")] - [TestCase(1_000_000_000_000_000, "1000TB")] - [TestCase(123_456_789_012_345, "123.46TB")] + [TestCase(1_000_000_000_000_000, "1PB")] + [TestCase(1_000_000_000_000_000_000, "1EB")] + [TestCase(long.MaxValue, "9.22EB")] + [TestCase(long.MinValue, "-9.22EB")] public void TestByteCountFormatting(long input, string expectedOutput) { Assert.That(input.FormatByteCount(), Is.EqualTo(expectedOutput)); diff --git a/tests/unit/InMemoryCacheTests.cs b/tests/unit/InMemoryCacheTests.cs index b01a530..f3db4e3 100644 --- a/tests/unit/InMemoryCacheTests.cs +++ b/tests/unit/InMemoryCacheTests.cs @@ -57,13 +57,15 @@ public void Test_AddedFile_Hits() [TestCase(1000, true)] [TestCase(1001, false)] [TestCase(10000, false)] - public void Test_AddedFile_TooLarge(int size, bool shouldPass) + public async Task Test_AddedFile_TooLarge(int size, bool shouldPass) { InMemoryCache cache = CreateCache(options => options.MaxSize = 1); const string TEST_PATH = "/hello.txt"; string testData = new string('*', size); cache.Set(TEST_PATH, Encoding.UTF8.GetBytes(testData), new DistributedCacheEntryOptions()); + await Task.Delay(100); // give the compacting task enough time to finish + if (shouldPass) { Assert.Multiple(() => @@ -88,8 +90,8 @@ public void Test_AddedFile_TooLarge(int size, bool shouldPass) [Test] public async Task Test_OldItems_AreEvicted() { - var purgeInterval = TimeSpan.FromSeconds(1); - InMemoryCache cache = CreateCache(options => options.PurgeInterval = purgeInterval); + var maxAge = TimeSpan.FromSeconds(1); + InMemoryCache cache = CreateCache(configureCache: options => options.MaxAge = maxAge); const string TEST_PATH = "/hello.txt"; const string TEST_DATA = "Hello, World!"; @@ -104,9 +106,7 @@ public async Task Test_OldItems_AreEvicted() Assert.That(cache.Get(TEST_PATH), Is.Not.Null); - await cache.StartAsync(CancellationToken.None); - - await Task.Delay(purgeInterval + TimeSpan.FromSeconds(1)); // wait for the purge interval with a small margin + await Task.Delay(maxAge + TimeSpan.FromSeconds(1)); // wait for the purge interval with a small margin Assert.That(cache.Get(TEST_PATH), Is.Null); #if RELEASE @@ -117,8 +117,6 @@ public async Task Test_OldItems_AreEvicted() Assert.That(weakRef.IsAlive, Is.False); #endif - - await cache.StopAsync(CancellationToken.None); } } }