From e16af744589bd1fb947b868b1457add233d05296 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 12:01:16 +0100 Subject: [PATCH 1/8] Enabled cache stampede protection and allow configuration of sliding expiration. --- Directory.Packages.props | 1 + .../LinkDotNet.Blog.Infrastructure.csproj | 5 ++- .../Persistence/CachedRepository.cs | 39 +++++++++++++++---- .../StorageProviderExtensions.cs | 4 +- src/LinkDotNet.Blog.Web/ServiceExtensions.cs | 2 + .../Persistence/CachedRepositoryTests.cs | 9 +++-- .../Sql/BlogPostRepositoryTests.cs | 7 ++-- .../Persistence/CachedRepositoryTests.cs | 11 +++--- 8 files changed, 56 insertions(+), 22 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0ad50c80..4a1f8e13 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true + diff --git a/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj b/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj index ea6489f7..392d2cc4 100644 --- a/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj +++ b/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj @@ -8,13 +8,14 @@ + - + - + diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs index b5f7b14a..042f0c02 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq.Expressions; using System.Threading.Tasks; +using AsyncKeyedLock; using LinkDotNet.Blog.Domain; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -13,21 +14,45 @@ public sealed class CachedRepository : IRepository { private readonly IRepository repository; private readonly IMemoryCache memoryCache; + private readonly AsyncKeyedLocker asyncKeyedLocker; - public CachedRepository(IRepository repository, IMemoryCache memoryCache) + public CachedRepository(IRepository repository, IMemoryCache memoryCache, AsyncKeyedLocker asyncKeyedLocker) { this.repository = repository; this.memoryCache = memoryCache; + this.asyncKeyedLocker = asyncKeyedLocker; } public ValueTask PerformHealthCheckAsync() => repository.PerformHealthCheckAsync(); - public async ValueTask GetByIdAsync(string id) => - (await memoryCache.GetOrCreateAsync(id, async entry => + public async ValueTask GetByIdAsync(string id) => await GetByIdAsync(id, TimeSpan.FromDays(7)); + + public async ValueTask GetByIdAsync(string id, TimeSpan slidingExpiration) + { + if (memoryCache.TryGetValue(id, out T? cachedObj)) + { + return cachedObj; + } + + using (await asyncKeyedLocker.LockAsync(id)) { - entry.SlidingExpiration = TimeSpan.FromDays(7); - return await repository.GetByIdAsync(id); - }))!; + if (memoryCache.TryGetValue(id, out cachedObj)) + { + return cachedObj; + } + + var value = await repository.GetByIdAsync(id); + + var options = new MemoryCacheEntryOptions + { + SlidingExpiration = slidingExpiration + }; + + memoryCache.Set(id, value, options); + + return value; + } + } public async ValueTask> GetAllAsync(Expression>? filter = null, Expression>? orderBy = null, diff --git a/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs b/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs index 9498d37d..2a2a9c49 100644 --- a/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs +++ b/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs @@ -1,4 +1,5 @@ using System; +using AsyncKeyedLock; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; using Microsoft.Extensions.Caching.Memory; @@ -58,6 +59,7 @@ private static void RegisterCachedRepository(this IServiceCollection serv services.AddScoped(); services.AddScoped>(provider => new CachedRepository( provider.GetRequiredService(), - provider.GetRequiredService())); + provider.GetRequiredService(), + provider.GetRequiredService>())); } } diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 7d569325..00a5e3ee 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Threading.RateLimiting; +using AsyncKeyedLock; using Blazorise; using Blazorise.Bootstrap5; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; @@ -26,6 +27,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + services.AddSingleton>(); services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); services.AddSingleton(s => s.GetRequiredService()); diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs index d49703e0..f5f88b6f 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs @@ -1,5 +1,6 @@ -using System.Linq; +using System.Linq; using System.Threading.Tasks; +using AsyncKeyedLock; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure.Persistence; using LinkDotNet.Blog.TestUtilities; @@ -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 MemoryCache(new MemoryCacheOptions()), new AsyncKeyedLocker()); 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 MemoryCache(new MemoryCacheOptions()), new AsyncKeyedLocker()); 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 MemoryCache(new MemoryCacheOptions()), new AsyncKeyedLocker()); 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..ddd23e98 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 AsyncKeyedLock; 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 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 MemoryCache(new MemoryCacheOptions()), new AsyncKeyedLocker()); 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.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs b/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs index fb3baa1d..8d6f9aa6 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 AsyncKeyedLock; 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; 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 MemoryCache(new MemoryCacheOptions()), new AsyncKeyedLocker()); } [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 +} From a764c8609bc6586f5a0cfce8082dd9ebedb9f8ca Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 12:07:00 +0100 Subject: [PATCH 2/8] Fix order of using --- .../Persistence/Sql/BlogPostRepositoryTests.cs | 4 ++-- .../Infrastructure/Persistence/CachedRepositoryTests.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs index ddd23e98..6e4a1535 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs @@ -1,11 +1,11 @@ +using System.Linq; +using System.Threading.Tasks; using AsyncKeyedLock; 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 TestContext = Xunit.TestContext; namespace LinkDotNet.Blog.IntegrationTests.Infrastructure.Persistence.Sql; diff --git a/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs b/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs index 8d6f9aa6..5ff319ee 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs @@ -1,12 +1,12 @@ +using System; +using System.Linq.Expressions; +using System.Threading.Tasks; using AsyncKeyedLock; 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; namespace LinkDotNet.Blog.UnitTests.Infrastructure.Persistence; From 833f2e1d34ace48207f5e387f18f220f8fa805ec Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 14:46:35 +0100 Subject: [PATCH 3/8] Switched to using FusionCache. --- Directory.Packages.props | 4 +- .../LinkDotNet.Blog.Infrastructure.csproj | 2 +- .../Persistence/CachedRepository.cs | 43 ++++--------------- .../Controller/SitemapController.cs | 20 ++++----- .../Features/Home/Index.razor | 34 +++++++-------- .../StorageProviderExtensions.cs | 13 +++--- .../Persistence/CachedRepositoryTests.cs | 14 +++--- .../Sql/BlogPostRepositoryTests.cs | 10 ++--- .../Persistence/CachedRepositoryTests.cs | 12 +++--- 9 files changed, 61 insertions(+), 91 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a1f8e13..da7a2bcf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,6 @@ true - @@ -21,6 +20,7 @@ + @@ -59,4 +59,4 @@ - \ No newline at end of file + diff --git a/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj b/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj index 392d2cc4..c3ccc608 100644 --- a/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj +++ b/src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj @@ -8,7 +8,6 @@ - @@ -17,6 +16,7 @@ + diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs index 042f0c02..66cabd40 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs @@ -2,10 +2,9 @@ using System.Collections.Generic; using System.Linq.Expressions; using System.Threading.Tasks; -using AsyncKeyedLock; using LinkDotNet.Blog.Domain; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZiggyCreatures.Caching.Fusion; namespace LinkDotNet.Blog.Infrastructure.Persistence; @@ -13,46 +12,22 @@ public sealed class CachedRepository : IRepository where T : Entity { private readonly IRepository repository; - private readonly IMemoryCache memoryCache; - private readonly AsyncKeyedLocker asyncKeyedLocker; + private readonly IFusionCache fusionCache; - public CachedRepository(IRepository repository, IMemoryCache memoryCache, AsyncKeyedLocker asyncKeyedLocker) + public CachedRepository(IRepository repository, IFusionCache fusionCache) { this.repository = repository; - this.memoryCache = memoryCache; - this.asyncKeyedLocker = asyncKeyedLocker; + this.fusionCache = fusionCache; } public ValueTask PerformHealthCheckAsync() => repository.PerformHealthCheckAsync(); public async ValueTask GetByIdAsync(string id) => await GetByIdAsync(id, TimeSpan.FromDays(7)); - public async ValueTask GetByIdAsync(string id, TimeSpan slidingExpiration) - { - if (memoryCache.TryGetValue(id, out T? cachedObj)) + public async ValueTask GetByIdAsync(string id, TimeSpan slidingExpiration) => await fusionCache.GetOrSetAsync(id, async c => { - return cachedObj; - } - - using (await asyncKeyedLocker.LockAsync(id)) - { - if (memoryCache.TryGetValue(id, out cachedObj)) - { - return cachedObj; - } - - var value = await repository.GetByIdAsync(id); - - var options = new MemoryCacheEntryOptions - { - SlidingExpiration = slidingExpiration - }; - - memoryCache.Set(id, value, options); - - return value; - } - } + return await repository.GetByIdAsync(id); + }, slidingExpiration); public async ValueTask> GetAllAsync(Expression>? filter = null, Expression>? orderBy = null, @@ -78,14 +53,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 2a2a9c49..2d7f638b 100644 --- a/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs +++ b/src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs @@ -1,10 +1,11 @@ -using System; -using AsyncKeyedLock; 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; @@ -14,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); @@ -59,7 +61,6 @@ private static void RegisterCachedRepository(this IServiceCollection serv services.AddScoped(); services.AddScoped>(provider => new CachedRepository( provider.GetRequiredService(), - 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 f5f88b6f..4ac61380 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs @@ -1,10 +1,10 @@ -using System.Linq; -using System.Threading.Tasks; -using AsyncKeyedLock; 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; @@ -20,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()), new AsyncKeyedLocker()); + 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"; @@ -35,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()), new AsyncKeyedLocker()); + var sut = new CachedRepository(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); await sut.StoreAsync(bp1); await sut.StoreAsync(bp2); await sut.GetAllAsync(); @@ -51,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()), new AsyncKeyedLocker()); + 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 6e4a1535..197fd4d4 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs @@ -1,11 +1,11 @@ -using System.Linq; -using System.Threading.Tasks; -using AsyncKeyedLock; 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; @@ -176,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()), new AsyncKeyedLocker()); + 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.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs b/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs index 5ff319ee..777b6f95 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs @@ -1,12 +1,12 @@ -using System; -using System.Linq.Expressions; -using System.Threading.Tasks; -using AsyncKeyedLock; 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; @@ -18,7 +18,7 @@ public sealed class CachedRepositoryTests public CachedRepositoryTests() { repositoryMock = Substitute.For>(); - sut = new CachedRepository(repositoryMock, new MemoryCache(new MemoryCacheOptions()), new AsyncKeyedLocker()); + sut = new CachedRepository(repositoryMock, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker())); } [Fact] From c3e78508df562caf6539bbf94542269870c0a3a4 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 15:01:34 +0100 Subject: [PATCH 4/8] Renamed variable, as FusionCache does not support SlidingExpiration. --- .../Persistence/CachedRepository.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs index 66cabd40..711df5e5 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs @@ -24,10 +24,10 @@ public CachedRepository(IRepository repository, IFusionCache fusionCache) public async ValueTask GetByIdAsync(string id) => await GetByIdAsync(id, TimeSpan.FromDays(7)); - public async ValueTask GetByIdAsync(string id, TimeSpan slidingExpiration) => await fusionCache.GetOrSetAsync(id, async c => + public async ValueTask GetByIdAsync(string id, TimeSpan expiration) => await fusionCache.GetOrSetAsync(id, async c => { return await repository.GetByIdAsync(id); - }, slidingExpiration); + }, expiration); public async ValueTask> GetAllAsync(Expression>? filter = null, Expression>? orderBy = null, From ff8f30995d7d359e6a669fbb95097caa029da974 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 18:34:15 +0100 Subject: [PATCH 5/8] Remove leftover singleton registration Removed singleton registration for AsyncKeyedLocker. --- src/LinkDotNet.Blog.Web/ServiceExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 00a5e3ee..af536eb3 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -27,7 +27,6 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); - services.AddSingleton>(); services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); services.AddSingleton(s => s.GetRequiredService()); From 9c9e09e3b0d18cddc267fefb72f9bc00e76c1447 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 18:34:59 +0100 Subject: [PATCH 6/8] Remove AsyncKeyedLock using directive Removed unused AsyncKeyedLock namespace. --- src/LinkDotNet.Blog.Web/ServiceExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index af536eb3..7d569325 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Threading.RateLimiting; -using AsyncKeyedLock; using Blazorise; using Blazorise.Bootstrap5; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; From ca6cb7911c88b59ae7c21fc53f0c8293c088c47e Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 20:48:25 +0100 Subject: [PATCH 7/8] Test fixes --- .../Web/Features/Home/IndexTests.cs | 3 +++ 1 file changed, 3 insertions(+) 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())); } } From 9b8005e7b589d82538f56e472b7a5c8536ac76be Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sat, 15 Nov 2025 22:29:04 +0100 Subject: [PATCH 8/8] Refactor GetByIdAsync to use fusionCache directly --- .../Persistence/CachedRepository.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs index 711df5e5..66cc62c1 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs @@ -22,12 +22,10 @@ public CachedRepository(IRepository repository, IFusionCache fusionCache) public ValueTask PerformHealthCheckAsync() => repository.PerformHealthCheckAsync(); - public async ValueTask GetByIdAsync(string id) => await GetByIdAsync(id, TimeSpan.FromDays(7)); - - public async ValueTask GetByIdAsync(string id, TimeSpan expiration) => await fusionCache.GetOrSetAsync(id, async c => + public async ValueTask GetByIdAsync(string id) => await fusionCache.GetOrSetAsync(id, async c => { return await repository.GetByIdAsync(id); - }, expiration); + }, TimeSpan.FromDays(7)); public async ValueTask> GetAllAsync(Expression>? filter = null, Expression>? orderBy = null,