diff --git a/samples/Server/Startup.cs b/samples/Server/Startup.cs index 5450fe3f..edceeb3b 100644 --- a/samples/Server/Startup.cs +++ b/samples/Server/Startup.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Options; using MudBlazor.Markdown.Core.Utils.ServiceRegistration; using MudBlazor.Services; @@ -21,8 +20,7 @@ public void ConfigureServices(IServiceCollection services) services.AddMudServices(); services.AddMudMarkdownServices(static cache => { - cache.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); - cache.SlidingExpiration = TimeSpan.FromHours(2); + cache.TimeToLive = TimeSpan.FromHours(2); }); } diff --git a/src/MudBlazor.Markdown/Models/MudMarkdownMemoryCacheEntryOptions.cs b/src/MudBlazor.Markdown/Models/MudMarkdownMemoryCacheEntryOptions.cs deleted file mode 100644 index 0bde4f69..00000000 --- a/src/MudBlazor.Markdown/Models/MudMarkdownMemoryCacheEntryOptions.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace MudBlazor; - -public sealed class MudMarkdownMemoryCacheEntryOptions : MemoryCacheEntryOptions; diff --git a/src/MudBlazor.Markdown/Models/MudMarkdownMemoryCacheOptions.cs b/src/MudBlazor.Markdown/Models/MudMarkdownMemoryCacheOptions.cs new file mode 100644 index 00000000..75aedfb6 --- /dev/null +++ b/src/MudBlazor.Markdown/Models/MudMarkdownMemoryCacheOptions.cs @@ -0,0 +1,6 @@ +namespace MudBlazor; + +public sealed class MudMarkdownMemoryCacheOptions +{ + public TimeSpan TimeToLive { get; set; } +} diff --git a/src/MudBlazor.Markdown/MudBlazor.Markdown.csproj b/src/MudBlazor.Markdown/MudBlazor.Markdown.csproj index b94065f2..972d0699 100644 --- a/src/MudBlazor.Markdown/MudBlazor.Markdown.csproj +++ b/src/MudBlazor.Markdown/MudBlazor.Markdown.csproj @@ -22,7 +22,6 @@ - diff --git a/src/MudBlazor.Markdown/Services/Interfaces/IMudMarkdownMemoryCache.cs b/src/MudBlazor.Markdown/Services/Interfaces/IMudMarkdownMemoryCache.cs index e06c0c8a..875275ae 100644 --- a/src/MudBlazor.Markdown/Services/Interfaces/IMudMarkdownMemoryCache.cs +++ b/src/MudBlazor.Markdown/Services/Interfaces/IMudMarkdownMemoryCache.cs @@ -1,3 +1,8 @@ namespace MudBlazor; -internal interface IMudMarkdownMemoryCache : IMemoryCache; +internal interface IMudMarkdownMemoryCache +{ + public bool TryGetValue(in string key, out string value); + + public void Set(in string key, in string value); +} diff --git a/src/MudBlazor.Markdown/Services/MudMarkdownMemoryCache.cs b/src/MudBlazor.Markdown/Services/MudMarkdownMemoryCache.cs index 228ba4c3..ba78a368 100644 --- a/src/MudBlazor.Markdown/Services/MudMarkdownMemoryCache.cs +++ b/src/MudBlazor.Markdown/Services/MudMarkdownMemoryCache.cs @@ -1,16 +1,88 @@ -using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace MudBlazor; -internal sealed class MudMarkdownMemoryCache : MemoryCache, IMudMarkdownMemoryCache +internal sealed class MudMarkdownMemoryCache : IMudMarkdownMemoryCache { - public MudMarkdownMemoryCache(IOptions optionsAccessor) - : base(optionsAccessor) +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#elif NET8_0 + private readonly object _lock = new(); +#endif + private readonly Dictionary _memoryCache = new(); + private readonly long _ttl; + + public MudMarkdownMemoryCache(IOptions options) + { + var timespan = options.Value.TimeToLive; + if (timespan <= TimeSpan.Zero) + timespan = TimeSpan.FromHours(1); + + _ttl = Convert.ToInt64(timespan.TotalSeconds); + } + + public bool TryGetValue(in string key, out string value) + { + Entry? entry; + lock (_lock) + { + if (!_memoryCache.TryGetValue(key, out entry)) + goto False; + } + + var currentTime = CurrentUnixTimeSeconds(); + if (entry.ExpiresAt > currentTime) + { + value = entry.Value; + return true; + } + + lock (_lock) + { + _memoryCache.Remove(key); + } + + False: + value = string.Empty; + return false; + } + + public void Set(in string key, in string value) { + lock (_lock) + { + ref var entryRef = ref CollectionsMarshal.GetValueRefOrAddDefault(_memoryCache, key, out _); + if (entryRef is null) + { + entryRef = new Entry(value) + { + ExpiresAt = GetExpiresAt(), + }; + } + else + { + entryRef.ExpiresAt = GetExpiresAt(); + } + } } - public MudMarkdownMemoryCache(IOptions optionsAccessor, ILoggerFactory loggerFactory) - : base(optionsAccessor, loggerFactory) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private long GetExpiresAt() => + CurrentUnixTimeSeconds() + _ttl; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static long CurrentUnixTimeSeconds() => + DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + private sealed class Entry { + public readonly string Value; + public long ExpiresAt; + + public Entry(in string value) + { + Value = value; + } } } diff --git a/src/MudBlazor.Markdown/Services/MudMarkdownValueProvider.cs b/src/MudBlazor.Markdown/Services/MudMarkdownValueProvider.cs index cf81bc4c..22f5cb80 100644 --- a/src/MudBlazor.Markdown/Services/MudMarkdownValueProvider.cs +++ b/src/MudBlazor.Markdown/Services/MudMarkdownValueProvider.cs @@ -5,13 +5,11 @@ namespace MudBlazor; internal sealed class MudMarkdownValueProvider : IMudMarkdownValueProvider { private readonly IMudMarkdownMemoryCache _memoryCache; - private readonly MemoryCacheEntryOptions _memoryCacheEntryOptions; private static readonly HttpClient HttpClient = new(); - public MudMarkdownValueProvider(IMudMarkdownMemoryCache memoryCache, IOptions options) + public MudMarkdownValueProvider(IMudMarkdownMemoryCache memoryCache) { _memoryCache = memoryCache; - _memoryCacheEntryOptions = options.Value; } public async ValueTask GetValueAsync(string value, MarkdownSourceType sourceType, CancellationToken ct = default) @@ -27,7 +25,7 @@ public async ValueTask GetValueAsync(string value, MarkdownSourceType so private async ValueTask ReadFromFileAsync(string path, CancellationToken ct = default) { - if (_memoryCache.TryGetValue(path, out var value) && value is not null) + if (_memoryCache.TryGetValue(path, out var value)) return value; try @@ -38,7 +36,7 @@ private async ValueTask ReadFromFileAsync(string path, CancellationToken value = await reader.ReadToEndAsync(ct) .ConfigureAwait(false); - _memoryCache.Set(path, value, _memoryCacheEntryOptions); + _memoryCache.Set(path, value); return value; } catch (Exception e) @@ -51,7 +49,7 @@ private async ValueTask ReadFromFileAsync(string path, CancellationToken private async ValueTask ReadFromUrlAsync(string url, CancellationToken ct = default) { - if (_memoryCache.TryGetValue(url, out var value) && value is not null) + if (_memoryCache.TryGetValue(url, out var value)) return value; try @@ -59,7 +57,7 @@ private async ValueTask ReadFromUrlAsync(string url, CancellationToken c value = await HttpClient.GetStringAsync(url, ct) .ConfigureAwait(false); - _memoryCache.Set(url, value, _memoryCacheEntryOptions); + _memoryCache.Set(url, value); return value; } catch (Exception e) diff --git a/src/MudBlazor.Markdown/Utils/ServiceRegistration/ServiceCollectionEx.cs b/src/MudBlazor.Markdown/Utils/ServiceRegistration/ServiceCollectionEx.cs index a118db02..eacd7fb6 100644 --- a/src/MudBlazor.Markdown/Utils/ServiceRegistration/ServiceCollectionEx.cs +++ b/src/MudBlazor.Markdown/Utils/ServiceRegistration/ServiceCollectionEx.cs @@ -2,7 +2,7 @@ public static class ServiceCollectionEx { - public static IServiceCollection AddMudMarkdownServices(this IServiceCollection @this, Action? configureMemoryCache = null) + public static IServiceCollection AddMudMarkdownServices(this IServiceCollection @this, Action? configureMemoryCache = null) { return @this .AddMudMarkdownCache(configureMemoryCache) @@ -10,22 +10,18 @@ public static IServiceCollection AddMudMarkdownServices(this IServiceCollection .AddSingleton(); } - private static IServiceCollection AddMudMarkdownCache(this IServiceCollection @this, Action? configureMemoryCache) + private static IServiceCollection AddMudMarkdownCache(this IServiceCollection @this, Action? configureMemoryCache) { return @this .AddOptions() - .AddSingleton() - .Configure(options => + .Configure(options => { - if (configureMemoryCache != null) - { + if (configureMemoryCache is not null) configureMemoryCache(options); - } else - { - options.SlidingExpiration = TimeSpan.FromHours(1); - } - }); + options.TimeToLive = TimeSpan.FromHours(1); + }) + .AddSingleton(); } public static IServiceCollection AddMudMarkdownClipboardService(this IServiceCollection @this) diff --git a/src/MudBlazor.Markdown/_Usings.cs b/src/MudBlazor.Markdown/_Usings.cs index 4fa7422f..c1722285 100644 --- a/src/MudBlazor.Markdown/_Usings.cs +++ b/src/MudBlazor.Markdown/_Usings.cs @@ -2,9 +2,7 @@ global using Markdig; global using Markdig.Helpers; global using Microsoft.AspNetCore.Components; -global using Microsoft.AspNetCore.Components.CompilerServices; global using Microsoft.AspNetCore.Components.Rendering; -global using Microsoft.Extensions.Caching.Memory; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Options; global using Microsoft.JSInterop; diff --git a/tests/MudBlazor.Markdown.Tests/Assertions.cs b/tests/MudBlazor.Markdown.Tests/Assertions.cs new file mode 100644 index 00000000..2a8c10ad --- /dev/null +++ b/tests/MudBlazor.Markdown.Tests/Assertions.cs @@ -0,0 +1,72 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.CompilerServices; +using FluentAssertions.Primitives; + +namespace MudBlazor.Markdown.Tests; + +internal static class MudMarkdownMemoryCacheEx +{ +#if NET9_0_OR_GREATER + [OverloadResolutionPriority(1)] +#endif + public static MudMarkdownMemoryCacheAssertions Should(this IMudMarkdownMemoryCache? @this) + { + return new MudMarkdownMemoryCacheAssertions(@this); + } +} + +internal sealed class MudMarkdownMemoryCacheAssertions(IMudMarkdownMemoryCache? value) : ObjectAssertions(value) +{ + private FieldInfo? _cacheField; + + public void HaveEmptyCache() + { + var memoryCache = GetMemoryCache(); + + memoryCache.Count + .Should() + .Be(0); + } + + public void HaveSingleCacheEntry(string key) + { + var memoryCache = GetMemoryCache(); + + memoryCache.Keys.Count + .Should() + .Be(1); + + memoryCache.Keys + .Cast() + .Single() + .Should() + .Be(key); + } + + public void HaveKeys(IReadOnlyList keys) + { + var memoryCache = GetMemoryCache(); + + memoryCache.Keys + .Should() + .BeEquivalentTo(keys); + } + + private IDictionary GetMemoryCache() + { + var field = GetMemoryCacheField(); + return (IDictionary?)field.GetValue(Subject) ?? throw new InvalidOperationException("Memory cache is null."); + } + + private FieldInfo GetMemoryCacheField() + { + if (_cacheField is not null) + return _cacheField; + + var cacheField = Subject?.GetType() + .GetField("_memoryCache", BindingFlags.Instance | BindingFlags.NonPublic); + + return _cacheField = cacheField ?? throw new InvalidOperationException("Cache field not found."); + } +} diff --git a/tests/MudBlazor.Markdown.Tests/MudBlazor.Markdown.Tests.csproj b/tests/MudBlazor.Markdown.Tests/MudBlazor.Markdown.Tests.csproj index 74c7e8fb..b6580915 100644 --- a/tests/MudBlazor.Markdown.Tests/MudBlazor.Markdown.Tests.csproj +++ b/tests/MudBlazor.Markdown.Tests/MudBlazor.Markdown.Tests.csproj @@ -14,7 +14,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/MudMarkdownMemoryCacheTestsBase.cs b/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/MudMarkdownMemoryCacheTestsBase.cs new file mode 100644 index 00000000..9bd5feef --- /dev/null +++ b/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/MudMarkdownMemoryCacheTestsBase.cs @@ -0,0 +1,27 @@ +namespace MudBlazor.Markdown.Tests.Services.MudMarkdownMemoryCacheTests; + +public abstract class MudMarkdownMemoryCacheTestsBase : IDisposable +{ + private ServiceProvider? _serviceProvider; + + protected TimeSpan? TimeToLive { get; set; } + + internal IMudMarkdownMemoryCache CreateFixture() + { + _serviceProvider ??= new ServiceCollection() + .AddMudMarkdownServices(opts => + { + if (TimeToLive.HasValue) + opts.TimeToLive = TimeToLive.Value; + }) + .BuildServiceProvider(); + + return _serviceProvider.GetRequiredService(); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + _serviceProvider?.Dispose(); + } +} diff --git a/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/SetShould.cs b/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/SetShould.cs new file mode 100644 index 00000000..6d4643ed --- /dev/null +++ b/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/SetShould.cs @@ -0,0 +1,35 @@ +namespace MudBlazor.Markdown.Tests.Services.MudMarkdownMemoryCacheTests; + +public sealed class SetShould : MudMarkdownMemoryCacheTestsBase +{ + [Fact] + public void SetDifferentKeys() + { + const string key1 = nameof(key1), + key2 = nameof(key2), + value = nameof(value); + + var fixture = CreateFixture(); + fixture.Set(key1, value); + fixture.Set(key2, value); + + fixture + .Should() + .HaveKeys([key1, key2]); + } + + [Fact] + public void SetSameKeys() + { + const string key = nameof(key), value = nameof(value); + + var fixture = CreateFixture(); + fixture.Set(key, value); + fixture.Set(key, value); + fixture.Set(key, value); + + fixture + .Should() + .HaveSingleCacheEntry(key); + } +} diff --git a/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/TryGetValueShould.cs b/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/TryGetValueShould.cs new file mode 100644 index 00000000..2cad024e --- /dev/null +++ b/tests/MudBlazor.Markdown.Tests/Services/MudMarkdownMemoryCacheTests/TryGetValueShould.cs @@ -0,0 +1,98 @@ +namespace MudBlazor.Markdown.Tests.Services.MudMarkdownMemoryCacheTests; + +public sealed class TryGetValueShould : MudMarkdownMemoryCacheTestsBase +{ + [Fact] + public void ReturnFalseIfNotSet() + { + const string key = nameof(key); + + var fixture = CreateFixture(); + var actual = fixture.TryGetValue(key, out var actualValue); + + actual + .Should() + .BeFalse(); + + actualValue + .Should() + .BeEmpty(); + + fixture + .Should() + .HaveEmptyCache(); + } + + [Fact] + public void ReturnTrueIfSet() + { + const string key = nameof(key), value = nameof(value); + + var fixture = CreateFixture(); + fixture.Set(key, value); + + var actual = fixture.TryGetValue(key, out var actualValue); + + actual + .Should() + .BeTrue(); + + actualValue + .Should() + .Be(value); + + fixture + .Should() + .HaveSingleCacheEntry(key); + } + + [Fact] + public async Task ReturnFalseAfterExpiration() + { + TimeToLive = TimeSpan.FromSeconds(1); + const string key = nameof(key), value = nameof(value); + + var fixture = CreateFixture(); + fixture.Set(key, value); + + await Task.Delay(TimeToLive.Value); + + var actual = fixture.TryGetValue(key, out var actualValue); + + actual + .Should() + .BeFalse(); + + actualValue + .Should() + .BeEmpty(); + + fixture + .Should() + .HaveEmptyCache(); + } + + [Fact] + public async Task ReturnTrueAfterExtendedExpiresAt() + { + TimeToLive = TimeSpan.FromSeconds(2); + const string key = nameof(key), value = nameof(value); + + var fixture = CreateFixture(); + fixture.Set(key, value); + await Task.Delay(TimeSpan.FromSeconds(1)); + + fixture.Set(key, value); + await Task.Delay(TimeSpan.FromSeconds(1.01d)); + + var actual = fixture.TryGetValue(key, out var actualValue); + + actual + .Should() + .BeTrue(); + + actualValue + .Should() + .Be(value); + } +} diff --git a/tests/MudBlazor.Markdown.Tests/Utils/ServiceCollectionExTests/AddMudMarkdownServicesShould.cs b/tests/MudBlazor.Markdown.Tests/Utils/ServiceCollectionExTests/AddMudMarkdownServicesShould.cs index ed9b6205..70035894 100644 --- a/tests/MudBlazor.Markdown.Tests/Utils/ServiceCollectionExTests/AddMudMarkdownServicesShould.cs +++ b/tests/MudBlazor.Markdown.Tests/Utils/ServiceCollectionExTests/AddMudMarkdownServicesShould.cs @@ -42,24 +42,6 @@ public void RegisterCustomMemoryCache() .Be(scopedInstance); } - [Fact] - public void RegisterScopedMemoryCache() - { - using var fixture = CreateFixture() - .AddScoped() - .AddMudMarkdownServices() - .BuildServiceProvider(); - - var instance = fixture.GetRequiredService(); - - using var scope = fixture.CreateScope(); - var scopedInstance = scope.ServiceProvider.GetRequiredService(); - - instance - .Should() - .NotBe(scopedInstance); - } - [Fact] public void RegisterServices() {