Skip to content

Commit 4ad0bd0

Browse files
Enabled cache stampede protection (#443)
* Enabled cache stampede protection and allow configuration of sliding expiration. * Fix order of using * Switched to using FusionCache. * Renamed variable, as FusionCache does not support SlidingExpiration. * Remove leftover singleton registration Removed singleton registration for AsyncKeyedLocker. * Remove AsyncKeyedLock using directive Removed unused AsyncKeyedLock namespace. * Test fixes * Refactor GetByIdAsync to use fusionCache directly
1 parent 8d50e19 commit 4ad0bd0

File tree

10 files changed

+64
-61
lines changed

10 files changed

+64
-61
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
2121
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
2222
<PackageVersion Include="RavenDB.Client" Version="7.1.4" />
23+
<PackageVersion Include="ZiggyCreatures.FusionCache.Locking.AsyncKeyed" Version="2.4.0" />
2324
</ItemGroup>
2425
<ItemGroup Label="Web">
2526
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />

src/LinkDotNet.Blog.Infrastructure/LinkDotNet.Blog.Infrastructure.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
1515
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL"/>
1616
<PackageReference Include="RavenDB.Client" />
17+
<PackageReference Include="ZiggyCreatures.FusionCache.Locking.AsyncKeyed" />
1718
</ItemGroup>
1819

1920
<ItemGroup>

src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,31 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.Linq.Expressions;
44
using System.Threading.Tasks;
55
using LinkDotNet.Blog.Domain;
6-
using Microsoft.Extensions.Caching.Memory;
76
using Microsoft.Extensions.Diagnostics.HealthChecks;
7+
using ZiggyCreatures.Caching.Fusion;
88

99
namespace LinkDotNet.Blog.Infrastructure.Persistence;
1010

1111
public sealed class CachedRepository<T> : IRepository<T>
1212
where T : Entity
1313
{
1414
private readonly IRepository<T> repository;
15-
private readonly IMemoryCache memoryCache;
15+
private readonly IFusionCache fusionCache;
1616

17-
public CachedRepository(IRepository<T> repository, IMemoryCache memoryCache)
17+
public CachedRepository(IRepository<T> repository, IFusionCache fusionCache)
1818
{
1919
this.repository = repository;
20-
this.memoryCache = memoryCache;
20+
this.fusionCache = fusionCache;
2121
}
2222

2323
public ValueTask<HealthCheckResult> PerformHealthCheckAsync() => repository.PerformHealthCheckAsync();
2424

25-
public async ValueTask<T?> GetByIdAsync(string id) =>
26-
(await memoryCache.GetOrCreateAsync(id, async entry =>
25+
public async ValueTask<T?> GetByIdAsync(string id) => await fusionCache.GetOrSetAsync(id, async c =>
2726
{
28-
entry.SlidingExpiration = TimeSpan.FromDays(7);
2927
return await repository.GetByIdAsync(id);
30-
}))!;
28+
}, TimeSpan.FromDays(7));
3129

3230
public async ValueTask<IPagedList<T>> GetAllAsync(Expression<Func<T, bool>>? filter = null,
3331
Expression<Func<T, object>>? orderBy = null,
@@ -53,14 +51,14 @@ public async ValueTask StoreAsync(T entity)
5351

5452
if (!string.IsNullOrEmpty(entity.Id))
5553
{
56-
memoryCache.Remove(entity.Id);
54+
await fusionCache.RemoveAsync(entity.Id);
5755
}
5856
}
5957

6058
public async ValueTask DeleteAsync(string id)
6159
{
6260
await repository.DeleteAsync(id);
63-
memoryCache.Remove(id);
61+
await fusionCache.RemoveAsync(id);
6462
}
6563

6664
public async ValueTask DeleteBulkAsync(IReadOnlyCollection<string> ids) => await repository.DeleteBulkAsync(ids);

src/LinkDotNet.Blog.Web/Controller/SitemapController.cs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
using System;
2-
using System.Threading.Tasks;
31
using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services;
42
using Microsoft.AspNetCore.Mvc;
53
using Microsoft.AspNetCore.RateLimiting;
6-
using Microsoft.Extensions.Caching.Memory;
4+
using System;
5+
using System.Threading.Tasks;
6+
using ZiggyCreatures.Caching.Fusion;
77

88
namespace LinkDotNet.Blog.Web.Controller;
99

@@ -13,28 +13,24 @@ public sealed class SitemapController : ControllerBase
1313
{
1414
private readonly ISitemapService sitemapService;
1515
private readonly IXmlWriter xmlWriter;
16-
private readonly IMemoryCache memoryCache;
16+
private readonly IFusionCache fusionCache;
1717

1818
public SitemapController(
1919
ISitemapService sitemapService,
2020
IXmlWriter xmlWriter,
21-
IMemoryCache memoryCache)
21+
IFusionCache fusionCache)
2222
{
2323
this.sitemapService = sitemapService;
2424
this.xmlWriter = xmlWriter;
25-
this.memoryCache = memoryCache;
25+
this.fusionCache = fusionCache;
2626
}
2727

2828
[ResponseCache(Duration = 3600)]
2929
[HttpGet]
3030
public async Task<IActionResult> GetSitemap()
3131
{
32-
var buffer = await memoryCache.GetOrCreateAsync("sitemap.xml", async e =>
33-
{
34-
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
35-
return await GetSitemapBuffer();
36-
})
37-
?? throw new InvalidOperationException("Buffer is null");
32+
var buffer = await fusionCache.GetOrSetAsync("sitemap.xml", async e => await GetSitemapBuffer(), o => o.SetDuration(TimeSpan.FromHours(1)))
33+
?? throw new InvalidOperationException("Buffer is null");
3834

3935
return File(buffer, "application/xml");
4036
}

src/LinkDotNet.Blog.Web/Features/Home/Index.razor

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@page "/"
1+
@page "/"
22
@page "/{page:int}"
33
@using Markdig
44
@using LinkDotNet.Blog.Domain
@@ -8,7 +8,8 @@
88
@using LinkDotNet.Blog.Web.Features.Services
99
@using Microsoft.Extensions.Caching.Memory
1010
@using Microsoft.Extensions.Primitives
11-
@inject IMemoryCache MemoryCache
11+
@using ZiggyCreatures.Caching.Fusion
12+
@inject IFusionCache FusionCache
1213
@inject ICacheTokenProvider CacheTokenProvider
1314
@inject IRepository<BlogPost> BlogPostRepository
1415
@inject IOptions<Introduction> Introduction
@@ -49,27 +50,24 @@
4950

5051
protected override async Task OnParametersSetAsync()
5152
{
52-
const string firstPageCacheKey = "BlogPostList";
53+
const string firstPageCacheKey = "BlogPostList";
5354
if (Page is null or < 1)
5455
{
5556
Page = 1;
5657
}
5758

58-
// The hot path is that users land on the initial page which is the first page.
59-
// So we want to cache that page for a while to reduce the load on the database
60-
// and to speed up the page load.
61-
// That will lead to stale blog posts for x minutes (worst case) for the first page,
62-
// but I am fine with that (as publishing isn't super critical and not done multiple times per hour).
63-
// This cache can be manually invalidated in the Admin UI (settings)
64-
if (Page == 1)
65-
{
66-
currentPage = (await MemoryCache.GetOrCreateAsync(firstPageCacheKey, async entry =>
67-
{
68-
var cacheDuration = TimeSpan.FromMinutes(AppConfiguration.Value.FirstPageCacheDurationInMinutes);
69-
entry.AbsoluteExpirationRelativeToNow = cacheDuration;
70-
entry.AddExpirationToken(new CancellationChangeToken(CacheTokenProvider.Token));
71-
return await GetAllForPageAsync(1);
72-
}))!;
59+
// The hot path is that users land on the initial page which is the first page.
60+
// So we want to cache that page for a while to reduce the load on the database
61+
// and to speed up the page load.
62+
// That will lead to stale blog posts for x minutes (worst case) for the first page,
63+
// but I am fine with that (as publishing isn't super critical and not done multiple times per hour).
64+
// This cache can be manually invalidated in the Admin UI (settings)
65+
if (Page == 1)
66+
{
67+
currentPage = await FusionCache.GetOrSetAsync(firstPageCacheKey, async e => await GetAllForPageAsync(1), o =>
68+
{
69+
o.SetDuration(TimeSpan.FromMinutes(AppConfiguration.Value.FirstPageCacheDurationInMinutes));
70+
});
7371
return;
7472
}
7573

src/LinkDotNet.Blog.Web/RegistrationExtensions/StorageProviderExtensions.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
using System;
21
using LinkDotNet.Blog.Domain;
32
using LinkDotNet.Blog.Infrastructure.Persistence;
4-
using Microsoft.Extensions.Caching.Memory;
53
using Microsoft.Extensions.Configuration;
64
using Microsoft.Extensions.DependencyInjection;
5+
using System;
6+
using ZiggyCreatures.Caching.Fusion;
7+
using ZiggyCreatures.Caching.Fusion.Locking;
8+
using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed;
79

810
namespace LinkDotNet.Blog.Web.RegistrationExtensions;
911

@@ -13,7 +15,8 @@ public static IServiceCollection AddStorageProvider(this IServiceCollection serv
1315
{
1416
ArgumentNullException.ThrowIfNull(configuration);
1517

16-
services.AddMemoryCache();
18+
services.AddSingleton<IFusionCacheMemoryLocker, AsyncKeyedMemoryLocker>();
19+
services.AddFusionCache().WithRegisteredMemoryLocker();
1720

1821
var provider = configuration["PersistenceProvider"] ?? throw new InvalidOperationException("No persistence provider configured");
1922
var persistenceProvider = PersistenceProvider.Create(provider);
@@ -58,6 +61,6 @@ private static void RegisterCachedRepository<TRepo>(this IServiceCollection serv
5861
services.AddScoped<TRepo>();
5962
services.AddScoped<IRepository<BlogPost>>(provider => new CachedRepository<BlogPost>(
6063
provider.GetRequiredService<TRepo>(),
61-
provider.GetRequiredService<IMemoryCache>()));
64+
provider.GetRequiredService<IFusionCache>()));
6265
}
6366
}

tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/CachedRepositoryTests.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
using System.Linq;
2-
using System.Threading.Tasks;
31
using LinkDotNet.Blog.Domain;
42
using LinkDotNet.Blog.Infrastructure.Persistence;
53
using LinkDotNet.Blog.TestUtilities;
6-
using Microsoft.Extensions.Caching.Memory;
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using ZiggyCreatures.Caching.Fusion;
7+
using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed;
78

89
namespace LinkDotNet.Blog.IntegrationTests.Infrastructure.Persistence;
910

@@ -19,7 +20,7 @@ public async Task ShouldNotCacheWhenDifferentQueries()
1920
await Repository.StoreAsync(bp2);
2021
await Repository.StoreAsync(bp3);
2122
var searchTerm = "tag 1";
22-
var sut = new CachedRepository<BlogPost>(Repository, new MemoryCache(new MemoryCacheOptions()));
23+
var sut = new CachedRepository<BlogPost>(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
2324
await sut.GetAllAsync(f => f.Tags.Any(t => t == searchTerm));
2425
searchTerm = "tag 2";
2526

@@ -34,7 +35,7 @@ public async Task ShouldResetOnDelete()
3435
{
3536
var bp1 = new BlogPostBuilder().WithTitle("1").Build();
3637
var bp2 = new BlogPostBuilder().WithTitle("2").Build();
37-
var sut = new CachedRepository<BlogPost>(Repository, new MemoryCache(new MemoryCacheOptions()));
38+
var sut = new CachedRepository<BlogPost>(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
3839
await sut.StoreAsync(bp1);
3940
await sut.StoreAsync(bp2);
4041
await sut.GetAllAsync();
@@ -50,7 +51,7 @@ public async Task ShouldResetOnSave()
5051
{
5152
var bp1 = new BlogPostBuilder().WithTitle("1").Build();
5253
var bp2 = new BlogPostBuilder().WithTitle("2").Build();
53-
var sut = new CachedRepository<BlogPost>(Repository, new MemoryCache(new MemoryCacheOptions()));
54+
var sut = new CachedRepository<BlogPost>(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
5455
await sut.StoreAsync(bp1);
5556
await sut.GetAllAsync();
5657
bp1.Update(bp2);

tests/LinkDotNet.Blog.IntegrationTests/Infrastructure/Persistence/Sql/BlogPostRepositoryTests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
using System.Linq;
2-
using System.Threading.Tasks;
31
using LinkDotNet.Blog.Domain;
42
using LinkDotNet.Blog.Infrastructure.Persistence;
53
using LinkDotNet.Blog.TestUtilities;
64
using Microsoft.EntityFrameworkCore;
7-
using Microsoft.Extensions.Caching.Memory;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
using ZiggyCreatures.Caching.Fusion;
8+
using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed;
89
using TestContext = Xunit.TestContext;
910

1011
namespace LinkDotNet.Blog.IntegrationTests.Infrastructure.Persistence.Sql;
@@ -175,7 +176,7 @@ public async Task ShouldDelete()
175176
public async Task GivenBlogPostWithTags_WhenLoadingAndDeleting_ThenShouldBeUpdated()
176177
{
177178
var bp = new BlogPostBuilder().WithTags("tag 1").Build();
178-
var sut = new CachedRepository<BlogPost>(Repository, new MemoryCache(new MemoryCacheOptions()));
179+
var sut = new CachedRepository<BlogPost>(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
179180
await sut.StoreAsync(bp);
180181
var updateBp = new BlogPostBuilder().WithTags("tag 2").Build();
181182
var bpFromCache = await sut.GetByIdAsync(bp.Id);

tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
using LinkDotNet.Blog.Web.Features.Services;
1111
using Microsoft.Extensions.DependencyInjection;
1212
using Microsoft.Extensions.Options;
13+
using ZiggyCreatures.Caching.Fusion;
14+
using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed;
1315

1416
namespace LinkDotNet.Blog.IntegrationTests.Web.Features.Home;
1517

@@ -225,5 +227,6 @@ private void RegisterComponents(BunitContext ctx, string? profilePictureUri = nu
225227
ctx.Services.AddScoped(_ => Options.Create(CreateSampleAppConfiguration(profilePictureUri, useMultiAuthorMode).Introduction));
226228
ctx.Services.AddScoped(_ => Substitute.For<ICacheTokenProvider>());
227229
ctx.Services.AddScoped(_ => Substitute.For<IBookmarkService>());
230+
ctx.Services.AddScoped<IFusionCache>(_ => new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
228231
}
229232
}

tests/LinkDotNet.Blog.UnitTests/Infrastructure/Persistence/CachedRepositoryTests.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
using System;
2-
using System.Linq.Expressions;
3-
using System.Threading.Tasks;
41
using LinkDotNet.Blog.Domain;
52
using LinkDotNet.Blog.Infrastructure;
63
using LinkDotNet.Blog.Infrastructure.Persistence;
74
using LinkDotNet.Blog.TestUtilities;
8-
using Microsoft.Extensions.Caching.Memory;
5+
using System;
6+
using System.Linq.Expressions;
7+
using System.Threading.Tasks;
8+
using ZiggyCreatures.Caching.Fusion;
9+
using ZiggyCreatures.Caching.Fusion.Locking.AsyncKeyed;
910

1011
namespace LinkDotNet.Blog.UnitTests.Infrastructure.Persistence;
1112

@@ -17,7 +18,7 @@ public sealed class CachedRepositoryTests
1718
public CachedRepositoryTests()
1819
{
1920
repositoryMock = Substitute.For<IRepository<BlogPost>>();
20-
sut = new CachedRepository<BlogPost>(repositoryMock, new MemoryCache(new MemoryCacheOptions()));
21+
sut = new CachedRepository<BlogPost>(repositoryMock, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
2122
}
2223

2324
[Fact]
@@ -138,4 +139,4 @@ private void SetupRepository()
138139
Arg.Any<int>(),
139140
Arg.Any<int>()).Returns(new PagedList<BlogPost>([blogPost], 1, 1, 1));
140141
}
141-
}
142+
}

0 commit comments

Comments
 (0)