diff --git a/Directory.Packages.props b/Directory.Packages.props index 755c2d8f..e233a4e8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj b/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj index 6e65f1be..4ec1166f 100644 --- a/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj +++ b/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj @@ -14,6 +14,7 @@ + diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs index b5f7b14a..66cc62c1 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs @@ -1,10 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Linq.Expressions; using System.Threading.Tasks; using LinkDotNet.Blog.Domain; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZiggyCreatures.Caching.Fusion; namespace LinkDotNet.Blog.Infrastructure.Persistence; @@ -12,22 +12,20 @@ public sealed class CachedRepository : IRepository where T : Entity { private readonly IRepository repository; - private readonly IMemoryCache memoryCache; + private readonly IFusionCache fusionCache; - public CachedRepository(IRepository repository, IMemoryCache memoryCache) + public CachedRepository(IRepository repository, IFusionCache fusionCache) { this.repository = repository; - this.memoryCache = memoryCache; + this.fusionCache = fusionCache; } public ValueTask PerformHealthCheckAsync() => repository.PerformHealthCheckAsync(); - public async ValueTask GetByIdAsync(string id) => - (await memoryCache.GetOrCreateAsync(id, async entry => + public async ValueTask GetByIdAsync(string id) => await fusionCache.GetOrSetAsync(id, async c => { - entry.SlidingExpiration = TimeSpan.FromDays(7); return await repository.GetByIdAsync(id); - }))!; + }, TimeSpan.FromDays(7)); public async ValueTask> GetAllAsync(Expression>? filter = null, Expression>? orderBy = null, @@ -53,14 +51,14 @@ public async ValueTask StoreAsync(T entity) if (!string.IsNullOrEmpty(entity.Id)) { - memoryCache.Remove(entity.Id); + await fusionCache.RemoveAsync(entity.Id); } } public async ValueTask DeleteAsync(string id) { await repository.DeleteAsync(id); - memoryCache.Remove(id); + await fusionCache.RemoveAsync(id); } public async ValueTask DeleteBulkAsync(IReadOnlyCollection ids) => await repository.DeleteBulkAsync(ids); diff --git a/src/LinkDotNet.Blog.Web/Controller/SitemapController.cs b/src/LinkDotNet.Blog.Web/Controller/SitemapController.cs index 03397a33..1221eeec 100644 --- a/src/LinkDotNet.Blog.Web/Controller/SitemapController.cs +++ b/src/LinkDotNet.Blog.Web/Controller/SitemapController.cs @@ -1,9 +1,9 @@ -using System; -using System.Threading.Tasks; using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Caching.Memory; +using System; +using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; namespace LinkDotNet.Blog.Web.Controller; @@ -13,28 +13,24 @@ public sealed class SitemapController : ControllerBase { private readonly ISitemapService sitemapService; private readonly IXmlWriter xmlWriter; - private readonly IMemoryCache memoryCache; + private readonly IFusionCache fusionCache; public SitemapController( ISitemapService sitemapService, IXmlWriter xmlWriter, - IMemoryCache memoryCache) + IFusionCache fusionCache) { this.sitemapService = sitemapService; this.xmlWriter = xmlWriter; - this.memoryCache = memoryCache; + this.fusionCache = fusionCache; } [ResponseCache(Duration = 3600)] [HttpGet] public async Task GetSitemap() { - var buffer = await memoryCache.GetOrCreateAsync("sitemap.xml", async e => - { - e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); - return await GetSitemapBuffer(); - }) - ?? throw new InvalidOperationException("Buffer is null"); + var buffer = await fusionCache.GetOrSetAsync("sitemap.xml", async e => await GetSitemapBuffer(), o => o.SetDuration(TimeSpan.FromHours(1))) + ?? throw new InvalidOperationException("Buffer is null"); return File(buffer, "application/xml"); } diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Index.razor b/src/LinkDotNet.Blog.Web/Features/Home/Index.razor index 8997b330..a4c8678b 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Index.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Index.razor @@ -1,4 +1,4 @@ -@page "/" +@page "/" @page "/{page:int}" @using Markdig @using LinkDotNet.Blog.Domain @@ -8,7 +8,8 @@ @using LinkDotNet.Blog.Web.Features.Services @using Microsoft.Extensions.Caching.Memory @using Microsoft.Extensions.Primitives -@inject IMemoryCache MemoryCache +@using ZiggyCreatures.Caching.Fusion +@inject IFusionCache FusionCache @inject ICacheTokenProvider CacheTokenProvider @inject IRepository BlogPostRepository @inject IOptions Introduction @@ -49,27 +50,24 @@ protected override async Task OnParametersSetAsync() { - const string firstPageCacheKey = "BlogPostList"; + const string firstPageCacheKey = "BlogPostList"; if (Page is null or < 1) { Page = 1; } - // The hot path is that users land on the initial page which is the first page. - // So we want to cache that page for a while to reduce the load on the database - // and to speed up the page load. - // That will lead to stale blog posts for x minutes (worst case) for the first page, - // but I am fine with that (as publishing isn't super critical and not done multiple times per hour). - // This cache can be manually invalidated in the Admin UI (settings) - if (Page == 1) - { - currentPage = (await MemoryCache.GetOrCreateAsync(firstPageCacheKey, async entry => - { - var cacheDuration = TimeSpan.FromMinutes(AppConfiguration.Value.FirstPageCacheDurationInMinutes); - entry.AbsoluteExpirationRelativeToNow = cacheDuration; - entry.AddExpirationToken(new CancellationChangeToken(CacheTokenProvider.Token)); - return await GetAllForPageAsync(1); - }))!; + // The hot path is that users land on the initial page which is the first page. + // So we want to cache that page for a while to reduce the load on the database + // and to speed up the page load. + // That will lead to stale blog posts for x minutes (worst case) for the first page, + // but I am fine with that (as publishing isn't super critical and not done multiple times per hour). + // This cache can be manually invalidated in the Admin UI (settings) + if (Page == 1) + { + currentPage = await FusionCache.GetOrSetAsync(firstPageCacheKey, async e => await GetAllForPageAsync(1), o => + { + o.SetDuration(TimeSpan.FromMinutes(AppConfiguration.Value.FirstPageCacheDurationInMinutes)); + }); return; } diff --git a/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs b/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs index 9498d37d..2d7f638b 100644 --- a/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs +++ b/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs @@ -1,9 +1,11 @@ -using System; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using System; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Locking; +using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed; namespace LinkDotNet.Blog.Web.RegistrationExtensions; @@ -13,7 +15,8 @@ public static IServiceCollection AddStorageProvider(this IServiceCollection serv { ArgumentNullException.ThrowIfNull(configuration); - services.AddMemoryCache(); + services.AddSingleton(); + services.AddFusionCache().WithRegisteredMemoryLocker(); var provider = configuration["PersistenceProvider"] ?? throw new InvalidOperationException("No persistence provider configured"); var persistenceProvider = PersistenceProvider.Create(provider); @@ -58,6 +61,6 @@ private static void RegisterCachedRepository(this IServiceCollection serv services.AddScoped(); services.AddScoped>(provider => new CachedRepository( provider.GetRequiredService(), - provider.GetRequiredService())); + provider.GetRequiredService())); } } diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs index d49703e0..4ac61380 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs @@ -1,9 +1,10 @@ -using System.Linq; -using System.Threading.Tasks; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; using LinkDotNet.Blog.TestUtilities; -using Microsoft.Extensions.Caching.Memory; +using System.Linq; +using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed; namespace LinkDotNet.Blog.IntegrationTests.Infrastructure.Persistence; @@ -19,7 +20,7 @@ public async Task ShouldNotCacheWhenDifferentQueries() await Repository.StoreAsync(bp2); await Repository.StoreAsync(bp3); var searchTerm = "tag 1"; - var sut = new CachedRepository(Repository, new MemoryCache(new MemoryCacheOptions())); + var sut = new CachedRepository(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); await sut.GetAllAsync(f => f.Tags.Any(t => t == searchTerm)); searchTerm = "tag 2"; @@ -34,7 +35,7 @@ public async Task ShouldResetOnDelete() { var bp1 = new BlogPostBuilder().WithTitle("1").Build(); var bp2 = new BlogPostBuilder().WithTitle("2").Build(); - var sut = new CachedRepository(Repository, new MemoryCache(new MemoryCacheOptions())); + var sut = new CachedRepository(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); await sut.StoreAsync(bp1); await sut.StoreAsync(bp2); await sut.GetAllAsync(); @@ -50,7 +51,7 @@ public async Task ShouldResetOnSave() { var bp1 = new BlogPostBuilder().WithTitle("1").Build(); var bp2 = new BlogPostBuilder().WithTitle("2").Build(); - var sut = new CachedRepository(Repository, new MemoryCache(new MemoryCacheOptions())); + var sut = new CachedRepository(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); await sut.StoreAsync(bp1); await sut.GetAllAsync(); bp1.Update(bp2); diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs index d2ed4a13..197fd4d4 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs @@ -1,10 +1,11 @@ -using System.Linq; -using System.Threading.Tasks; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; using LinkDotNet.Blog.TestUtilities; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; +using System.Linq; +using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed; using TestContext = Xunit.TestContext; namespace LinkDotNet.Blog.IntegrationTests.Infrastructure.Persistence.Sql; @@ -175,7 +176,7 @@ public async Task ShouldDelete() public async Task GivenBlogPostWithTags_WhenLoadingAndDeleting_ThenShouldBeUpdated() { var bp = new BlogPostBuilder().WithTags("tag 1").Build(); - var sut = new CachedRepository(Repository, new MemoryCache(new MemoryCacheOptions())); + var sut = new CachedRepository(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); await sut.StoreAsync(bp); var updateBp = new BlogPostBuilder().WithTags("tag 2").Build(); var bpFromCache = await sut.GetByIdAsync(bp.Id); diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs index c88a72b3..33fba7b1 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs @@ -10,6 +10,8 @@ using LinkDotNet.Blog.Web.Features.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed; namespace LinkDotNet.Blog.IntegrationTests.Web.Features.Home; @@ -225,5 +227,6 @@ private void RegisterComponents(BunitContext ctx, string? profilePictureUri = nu ctx.Services.AddScoped(_ => Options.Create(CreateSampleAppConfiguration(profilePictureUri, useMultiAuthorMode).Introduction)); ctx.Services.AddScoped(_ => Substitute.For()); ctx.Services.AddScoped(_ => Substitute.For()); + ctx.Services.AddScoped(_ => new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); } } diff --git a/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs b/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs index fb3baa1d..777b6f95 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs @@ -1,11 +1,12 @@ -using System; -using System.Linq.Expressions; -using System.Threading.Tasks; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure; using LinkDotNet.Blog.Infrastructure.Persistence; using LinkDotNet.Blog.TestUtilities; -using Microsoft.Extensions.Caching.Memory; +using System; +using System.Linq.Expressions; +using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed; namespace LinkDotNet.Blog.UnitTests.Infrastructure.Persistence; @@ -17,7 +18,7 @@ public sealed class CachedRepositoryTests public CachedRepositoryTests() { repositoryMock = Substitute.For>(); - sut = new CachedRepository(repositoryMock, new MemoryCache(new MemoryCacheOptions())); + sut = new CachedRepository(repositoryMock, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); } [Fact] @@ -138,4 +139,4 @@ private void SetupRepository() Arg.Any(), Arg.Any()).Returns(new PagedList([blogPost], 1, 1, 1)); } -} \ No newline at end of file +}