Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageVersion Include="RavenDB.Client" Version="7.1.4" />
<PackageVersion Include="ZiggyCreatures.FusionCache.Locking.AsyncKeyed" Version="2.4.0" />
</ItemGroup>
<ItemGroup Label="Web">
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL"/>
<PackageReference Include="RavenDB.Client" />
<PackageReference Include="ZiggyCreatures.FusionCache.Locking.AsyncKeyed" />
</ItemGroup>

<ItemGroup>
Expand Down
20 changes: 9 additions & 11 deletions src/LinkDotNet.Blog.Infrastructure/Persistence/CachedRepository.cs
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
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;

public sealed class CachedRepository<T> : IRepository<T>
where T : Entity
{
private readonly IRepository<T> repository;
private readonly IMemoryCache memoryCache;
private readonly IFusionCache fusionCache;

public CachedRepository(IRepository<T> repository, IMemoryCache memoryCache)
public CachedRepository(IRepository<T> repository, IFusionCache fusionCache)
{
this.repository = repository;
this.memoryCache = memoryCache;
this.fusionCache = fusionCache;
}

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

public async ValueTask<T?> GetByIdAsync(string id) =>
(await memoryCache.GetOrCreateAsync(id, async entry =>
public async ValueTask<T?> 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<IPagedList<T>> GetAllAsync(Expression<Func<T, bool>>? filter = null,
Expression<Func<T, object>>? orderBy = null,
Expand All @@ -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<string> ids) => await repository.DeleteBulkAsync(ids);
Expand Down
20 changes: 8 additions & 12 deletions src/LinkDotNet.Blog.Web/Controller/SitemapController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<IActionResult> 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");
}
Expand Down
34 changes: 16 additions & 18 deletions src/LinkDotNet.Blog.Web/Features/Home/Index.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@page "/"
@page "/"
@page "/{page:int}"
@using Markdig
@using LinkDotNet.Blog.Domain
Expand All @@ -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<BlogPost> BlogPostRepository
@inject IOptions<Introduction> Introduction
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

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

services.AddMemoryCache();
services.AddSingleton<IFusionCacheMemoryLocker, AsyncKeyedMemoryLocker>();
services.AddFusionCache().WithRegisteredMemoryLocker();

var provider = configuration["PersistenceProvider"] ?? throw new InvalidOperationException("No persistence provider configured");
var persistenceProvider = PersistenceProvider.Create(provider);
Expand Down Expand Up @@ -58,6 +61,6 @@ private static void RegisterCachedRepository<TRepo>(this IServiceCollection serv
services.AddScoped<TRepo>();
services.AddScoped<IRepository<BlogPost>>(provider => new CachedRepository<BlogPost>(
provider.GetRequiredService<TRepo>(),
provider.GetRequiredService<IMemoryCache>()));
provider.GetRequiredService<IFusionCache>()));
}
}
Original file line number Diff line number Diff line change
@@ -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;

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

Expand All @@ -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<BlogPost>(Repository, new MemoryCache(new MemoryCacheOptions()));
var sut = new CachedRepository<BlogPost>(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
await sut.StoreAsync(bp1);
await sut.StoreAsync(bp2);
await sut.GetAllAsync();
Expand All @@ -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<BlogPost>(Repository, new MemoryCache(new MemoryCacheOptions()));
var sut = new CachedRepository<BlogPost>(Repository, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
await sut.StoreAsync(bp1);
await sut.GetAllAsync();
bp1.Update(bp2);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<BlogPost>(Repository, new MemoryCache(new MemoryCacheOptions()));
var sut = new CachedRepository<BlogPost>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<ICacheTokenProvider>());
ctx.Services.AddScoped(_ => Substitute.For<IBookmarkService>());
ctx.Services.AddScoped<IFusionCache>(_ => new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,7 +18,7 @@ public sealed class CachedRepositoryTests
public CachedRepositoryTests()
{
repositoryMock = Substitute.For<IRepository<BlogPost>>();
sut = new CachedRepository<BlogPost>(repositoryMock, new MemoryCache(new MemoryCacheOptions()));
sut = new CachedRepository<BlogPost>(repositoryMock, new FusionCache(new FusionCacheOptions(), memoryLocker: new AsyncKeyedMemoryLocker()));
}

[Fact]
Expand Down Expand Up @@ -138,4 +139,4 @@ private void SetupRepository()
Arg.Any<int>(),
Arg.Any<int>()).Returns(new PagedList<BlogPost>([blogPost], 1, 1, 1));
}
}
}