From c41e18e97efb43bc2c8c0fa190ee976272bf3f67 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 1 Mar 2024 17:12:54 +0100 Subject: [PATCH] feat: Add possiblity to configure and invalidate first page cache --- docs/Setup/Configuration.md | 2 + .../ApplicationConfiguration.cs | 2 + .../Admin/Settings/SettingsPage.razor | 38 +++++++++++++++++++ .../Features/Admin/Sitemap/SitemapPage.razor | 4 +- .../Features/BlogPostPublisher.cs | 13 ++++++- .../Home/Components/AccessControl.razor | 1 + .../Features/Home/Index.razor | 7 +++- .../Features/Services/CacheService.cs | 19 ++++++++++ .../Features/Services/ICacheInvalidator.cs | 6 +++ .../Features/Services/ICacheTokenProvider.cs | 8 ++++ src/LinkDotNet.Blog.Web/ServiceExtensions.cs | 4 ++ src/LinkDotNet.Blog.Web/appsettings.json | 1 + .../Web/Features/BlogPostPublisherTests.cs | 26 ++++++++++++- .../Web/Features/Home/IndexTests.cs | 1 + .../Admin/Settings/SettingsPageTests.cs | 26 +++++++++++++ 15 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/CacheService.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/ICacheInvalidator.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/Services/ICacheTokenProvider.cs create mode 100644 tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs diff --git a/docs/Setup/Configuration.md b/docs/Setup/Configuration.md index cb72943b..42430794 100644 --- a/docs/Setup/Configuration.md +++ b/docs/Setup/Configuration.md @@ -30,6 +30,7 @@ The appsettings.json file has a lot of options to customize the content of the b "LogoutUri": "" }, "BlogPostsPerPage": 10, + "FirstPageCacheDurationInMinutes": 10, "ProfileInformation": { "Name": "Steven Giesel", "Heading": "Software Engineer", @@ -73,6 +74,7 @@ The appsettings.json file has a lot of options to customize the content of the b | ClientSecret | string | | | LogoutUri | string | | | BlogPostsPerPage | int | Gives the amount of blog posts loaded and display per page. For more the user has to use the navigation | +| FirstPageCacheDurationInMinutes | int | The duration in minutes the first page is cached. | | AboutMeProfileInformation | node | Sets information for the About Me Page. If omitted the page is disabled completely | | Name | string | Name, which is displayed on top of the profile card | | Heading | string | Displayed under the name. For example job title | diff --git a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs index 7f7f5960..f5f195fe 100644 --- a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs @@ -12,6 +12,8 @@ public sealed record ApplicationConfiguration public int BlogPostsPerPage { get; init; } = 10; + public int FirstPageCacheDurationInMinutes { get; init; } = 5; + public bool IsAboutMeEnabled { get; set; } public bool IsGiscusEnabled { get; set; } diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor b/src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor new file mode 100644 index 00000000..141e0bdf --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor @@ -0,0 +1,38 @@ +@page "/settings" +@using LinkDotNet.Blog.Web.Features.Services +@inject IOptions ApplicationConfiguration +@inject ICacheInvalidator CacheInvalidator +@inject IToastService ToastService +@attribute [Authorize] + +
+

Settings

+ + + + + + + + + + + + + + + + + +
Configuration NameDescriptionValueActions
Cache Duration (First Page)Defines how long the first page remains cached before a refresh is required.
+ When a scheduled blog post is published, the cache is always invalidated.
+ The longer the cache lives, the longer it takes for the user to see updated content on the first page.
@ApplicationConfiguration.Value.FirstPageCacheDurationInMinutes Minutes
+
+ +@code { + private void InvalidateCache() + { + CacheInvalidator.Cancel(); + ToastService.ShowInfo("Cache was invalidated."); + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor b/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor index d1ac5958..2bc008e4 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor @@ -2,7 +2,7 @@ @using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services @inject ISitemapService SitemapService @attribute [Authorize] -

Sitemap

+

Sitemap

A sitemap is a file which lists all important links in a webpage. It helps crawler to find all of the important pages. Especially newer sites benefit from having a sitemap.xml. @@ -47,4 +47,4 @@ isGenerating = false; await SitemapService.SaveSitemapToFileAsync(sitemapUrlSet); } -} \ No newline at end of file +} diff --git a/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs b/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs index 570a7a46..d74eb2bb 100644 --- a/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs +++ b/src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs @@ -4,6 +4,7 @@ using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Infrastructure; using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Web.Features.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -14,10 +15,12 @@ public sealed partial class BlogPostPublisher : BackgroundService { private readonly IServiceProvider serviceProvider; private readonly ILogger logger; + private readonly ICacheInvalidator cacheInvalidator; - public BlogPostPublisher(IServiceProvider serviceProvider, ILogger logger) + public BlogPostPublisher(IServiceProvider serviceProvider, ICacheInvalidator cacheInvalidator, ILogger logger) { this.serviceProvider = serviceProvider; + this.cacheInvalidator = cacheInvalidator; this.logger = logger; } @@ -44,12 +47,18 @@ private async Task PublishScheduledBlogPostsAsync() using var scope = serviceProvider.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService>(); - foreach (var blogPost in await GetScheduledBlogPostsAsync(repository)) + var blogPostsToPublish = await GetScheduledBlogPostsAsync(repository); + foreach (var blogPost in blogPostsToPublish) { blogPost.Publish(); await repository.StoreAsync(blogPost); LogPublishedBlogPost(blogPost.Id); } + + if (blogPostsToPublish.Count > 0) + { + cacheInvalidator.Cancel(); + } } private async Task> GetScheduledBlogPostsAsync(IRepository repository) diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor b/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor index 4ed7addb..b7e13733 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor @@ -9,6 +9,7 @@

  • Create new
  • Show drafts
  • +
  • Show settings
  • Dashboard
  • diff --git a/src/LinkDotNet.Blog.Web/Features/Home/Index.razor b/src/LinkDotNet.Blog.Web/Features/Home/Index.razor index eb816530..33736609 100644 --- a/src/LinkDotNet.Blog.Web/Features/Home/Index.razor +++ b/src/LinkDotNet.Blog.Web/Features/Home/Index.razor @@ -5,8 +5,11 @@ @using LinkDotNet.Blog.Infrastructure @using LinkDotNet.Blog.Infrastructure.Persistence @using LinkDotNet.Blog.Web.Features.Home.Components +@using LinkDotNet.Blog.Web.Features.Services @using Microsoft.Extensions.Caching.Memory +@using Microsoft.Extensions.Primitives @inject IMemoryCache MemoryCache +@inject ICacheTokenProvider CacheTokenProvider @inject IRepository BlogPostRepository @inject IOptions Introduction @inject IOptions AppConfiguration @@ -61,7 +64,9 @@ { currentPage = await MemoryCache.GetOrCreateAsync(firstPageCacheKey, async entry => { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var cacheDuration = TimeSpan.FromMinutes(AppConfiguration.Value.FirstPageCacheDurationInMinutes); + entry.AbsoluteExpirationRelativeToNow = cacheDuration; + entry.AddExpirationToken(new CancellationChangeToken(CacheTokenProvider.Token)); return await GetAllForPageAsync(1); }); return; diff --git a/src/LinkDotNet.Blog.Web/Features/Services/CacheService.cs b/src/LinkDotNet.Blog.Web/Features/Services/CacheService.cs new file mode 100644 index 00000000..e867c607 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/CacheService.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; + +namespace LinkDotNet.Blog.Web.Features.Services; + +public sealed class CacheService : ICacheTokenProvider, ICacheInvalidator, IDisposable +{ + private CancellationTokenSource cancellationTokenSource = new(); + + public CancellationToken Token => cancellationTokenSource.Token; + + public void Cancel() + { + cancellationTokenSource.Cancel(); + cancellationTokenSource = new(); + } + + public void Dispose() => cancellationTokenSource.Dispose(); +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/ICacheInvalidator.cs b/src/LinkDotNet.Blog.Web/Features/Services/ICacheInvalidator.cs new file mode 100644 index 00000000..c9ca4051 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/ICacheInvalidator.cs @@ -0,0 +1,6 @@ +namespace LinkDotNet.Blog.Web.Features.Services; + +public interface ICacheInvalidator +{ + void Cancel(); +} diff --git a/src/LinkDotNet.Blog.Web/Features/Services/ICacheTokenProvider.cs b/src/LinkDotNet.Blog.Web/Features/Services/ICacheTokenProvider.cs new file mode 100644 index 00000000..43a12ceb --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Services/ICacheTokenProvider.cs @@ -0,0 +1,8 @@ +using System.Threading; + +namespace LinkDotNet.Blog.Web.Features.Services; + +public interface ICacheTokenProvider +{ + CancellationToken Token { get; } +} diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 9b9600a6..71fa3f5a 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -15,5 +15,9 @@ public static void RegisterServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(s => s.GetRequiredService()); } } diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index d554c662..10f48832 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -29,6 +29,7 @@ "ClientSecret": "" }, "BlogPostsPerPage": 10, + "FirstPageCacheDurationInMinutes": 10, "ProfileInformation": { "Name": "Steven Giesel", "Heading": "Software Engineer", diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/BlogPostPublisherTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/BlogPostPublisherTests.cs index d9115ebe..aba8e73e 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/BlogPostPublisherTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/BlogPostPublisherTests.cs @@ -4,6 +4,7 @@ using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.Web.Features; +using LinkDotNet.Blog.Web.Features.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -12,14 +13,17 @@ namespace LinkDotNet.Blog.IntegrationTests.Web.Features; public sealed class BlogPostPublisherTests : SqlDatabaseTestBase, IDisposable { private readonly BlogPostPublisher sut; + private readonly ICacheInvalidator cacheInvalidator; public BlogPostPublisherTests() { var serviceProvider = new ServiceCollection() .AddScoped(_ => Repository) .BuildServiceProvider(); + + cacheInvalidator = Substitute.For(); - sut = new BlogPostPublisher(serviceProvider, Substitute.For>()); + sut = new BlogPostPublisher(serviceProvider, cacheInvalidator, Substitute.For>()); } [Fact] @@ -39,6 +43,26 @@ public async Task ShouldPublishScheduledBlogPosts() (await Repository.GetByIdAsync(bp2.Id)).IsPublished.Should().BeTrue(); (await Repository.GetByIdAsync(bp3.Id)).IsPublished.Should().BeFalse(); } + + [Fact] + public async Task ShouldInvalidateCacheWhenPublishing() + { + var now = DateTime.Now; + var bp1 = new BlogPostBuilder().WithScheduledPublishDate(now.AddHours(-3)).IsPublished(false).Build(); + await Repository.StoreAsync(bp1); + + await sut.StartAsync(CancellationToken.None); + + cacheInvalidator.Received().Cancel(); + } + + [Fact] + public async Task ShouldNotInvalidateCacheWhenThereIsNothingToPublish() + { + await sut.StartAsync(CancellationToken.None); + + cacheInvalidator.DidNotReceive().Cancel(); + } public void Dispose() => sut?.Dispose(); } diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs index 33f7491a..54a19ebd 100644 --- a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/Home/IndexTests.cs @@ -167,5 +167,6 @@ private void RegisterComponents(TestContextBase ctx, string profilePictureUri = ctx.Services.AddScoped(_ => Repository); ctx.Services.AddScoped(_ => Options.Create(CreateSampleAppConfiguration(profilePictureUri).ApplicationConfiguration)); ctx.Services.AddScoped(_ => Options.Create(CreateSampleAppConfiguration(profilePictureUri).Introduction)); + ctx.Services.AddScoped(_ => Substitute.For()); } } diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs new file mode 100644 index 00000000..f2aac34f --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs @@ -0,0 +1,26 @@ +using Blazored.Toast.Services; +using LinkDotNet.Blog.Web; +using LinkDotNet.Blog.Web.Features.Admin.Settings; +using LinkDotNet.Blog.Web.Features.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace LinkDotNet.Blog.UnitTests.Web.Features.Admin.Settings; + +public class SettingsPageTests : TestContext +{ + [Fact] + public void GivenSettingsPage_WhenClicking_InvalidateCacheButton_TokenIsCancelled() + { + var cacheInvalidator = Substitute.For(); + Services.AddScoped(_ => cacheInvalidator); + Services.AddScoped(_ => Options.Create(new())); + Services.AddScoped(_ => Substitute.For()); + var cut = RenderComponent(); + var invalidateCacheButton = cut.Find("#invalidate-cache"); + + invalidateCacheButton.Click(); + + cacheInvalidator.Received(1).Cancel(); + } +}