Skip to content

Commit c41e18e

Browse files
committed
feat: Add possiblity to configure and invalidate first page cache
1 parent 313c53e commit c41e18e

File tree

15 files changed

+152
-6
lines changed

15 files changed

+152
-6
lines changed

docs/Setup/Configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The appsettings.json file has a lot of options to customize the content of the b
3030
"LogoutUri": ""
3131
},
3232
"BlogPostsPerPage": 10,
33+
"FirstPageCacheDurationInMinutes": 10,
3334
"ProfileInformation": {
3435
"Name": "Steven Giesel",
3536
"Heading": "Software Engineer",
@@ -73,6 +74,7 @@ The appsettings.json file has a lot of options to customize the content of the b
7374
| ClientSecret | string | |
7475
| LogoutUri | string | |
7576
| BlogPostsPerPage | int | Gives the amount of blog posts loaded and display per page. For more the user has to use the navigation |
77+
| FirstPageCacheDurationInMinutes | int | The duration in minutes the first page is cached. |
7678
| AboutMeProfileInformation | node | Sets information for the About Me Page. If omitted the page is disabled completely |
7779
| Name | string | Name, which is displayed on top of the profile card |
7880
| Heading | string | Displayed under the name. For example job title |

src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ public sealed record ApplicationConfiguration
1212

1313
public int BlogPostsPerPage { get; init; } = 10;
1414

15+
public int FirstPageCacheDurationInMinutes { get; init; } = 5;
16+
1517
public bool IsAboutMeEnabled { get; set; }
1618

1719
public bool IsGiscusEnabled { get; set; }
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
@page "/settings"
2+
@using LinkDotNet.Blog.Web.Features.Services
3+
@inject IOptions<ApplicationConfiguration> ApplicationConfiguration
4+
@inject ICacheInvalidator CacheInvalidator
5+
@inject IToastService ToastService
6+
@attribute [Authorize]
7+
8+
<div class="container-fluid ms-3">
9+
<h3>Settings</h3>
10+
<table class="table table-responsive table-hover">
11+
<thead>
12+
<tr>
13+
<th scope="col">Configuration Name</th>
14+
<th scope="col">Description</th>
15+
<th scope="col">Value</th>
16+
<th scope="col">Actions</th>
17+
</tr>
18+
</thead>
19+
<tbody>
20+
<tr>
21+
<td>Cache Duration (First Page)</td>
22+
<td>Defines how long the first page remains cached before a refresh is required.<br/>
23+
When a scheduled blog post is published, the cache is always invalidated.<br/>
24+
The longer the cache lives, the longer it takes for the user to see updated content on the first page.</td>
25+
<td>@ApplicationConfiguration.Value.FirstPageCacheDurationInMinutes Minutes</td>
26+
<td><button class="btn btn-warning" id="invalidate-cache" @onclick="InvalidateCache">Invalidate Cache</button></td>
27+
</tr>
28+
</tbody>
29+
</table>
30+
</div>
31+
32+
@code {
33+
private void InvalidateCache()
34+
{
35+
CacheInvalidator.Cancel();
36+
ToastService.ShowInfo("Cache was invalidated.");
37+
}
38+
}

src/LinkDotNet.Blog.Web/Features/Admin/Sitemap/SitemapPage.razor

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
@using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services
33
@inject ISitemapService SitemapService
44
@attribute [Authorize]
5-
<h3 xmlns="http://www.w3.org/1999/html">Sitemap</h3>
5+
<h3>Sitemap</h3>
66
<div class="row px-2">
77
<p>A sitemap is a file which lists all important links in a webpage. It helps crawler to find all of the
88
important pages. Especially newer sites benefit from having a sitemap.xml.
@@ -47,4 +47,4 @@
4747
isGenerating = false;
4848
await SitemapService.SaveSitemapToFileAsync(sitemapUrlSet);
4949
}
50-
}
50+
}

src/LinkDotNet.Blog.Web/Features/BlogPostPublisher.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using LinkDotNet.Blog.Domain;
55
using LinkDotNet.Blog.Infrastructure;
66
using LinkDotNet.Blog.Infrastructure.Persistence;
7+
using LinkDotNet.Blog.Web.Features.Services;
78
using Microsoft.Extensions.DependencyInjection;
89
using Microsoft.Extensions.Hosting;
910
using Microsoft.Extensions.Logging;
@@ -14,10 +15,12 @@ public sealed partial class BlogPostPublisher : BackgroundService
1415
{
1516
private readonly IServiceProvider serviceProvider;
1617
private readonly ILogger<BlogPostPublisher> logger;
18+
private readonly ICacheInvalidator cacheInvalidator;
1719

18-
public BlogPostPublisher(IServiceProvider serviceProvider, ILogger<BlogPostPublisher> logger)
20+
public BlogPostPublisher(IServiceProvider serviceProvider, ICacheInvalidator cacheInvalidator, ILogger<BlogPostPublisher> logger)
1921
{
2022
this.serviceProvider = serviceProvider;
23+
this.cacheInvalidator = cacheInvalidator;
2124
this.logger = logger;
2225
}
2326

@@ -44,12 +47,18 @@ private async Task PublishScheduledBlogPostsAsync()
4447
using var scope = serviceProvider.CreateScope();
4548
var repository = scope.ServiceProvider.GetRequiredService<IRepository<BlogPost>>();
4649

47-
foreach (var blogPost in await GetScheduledBlogPostsAsync(repository))
50+
var blogPostsToPublish = await GetScheduledBlogPostsAsync(repository);
51+
foreach (var blogPost in blogPostsToPublish)
4852
{
4953
blogPost.Publish();
5054
await repository.StoreAsync(blogPost);
5155
LogPublishedBlogPost(blogPost.Id);
5256
}
57+
58+
if (blogPostsToPublish.Count > 0)
59+
{
60+
cacheInvalidator.Cancel();
61+
}
5362
}
5463

5564
private async Task<IPagedList<BlogPost>> GetScheduledBlogPostsAsync(IRepository<BlogPost> repository)

src/LinkDotNet.Blog.Web/Features/Home/Components/AccessControl.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<li><h6 class="dropdown-header">Blog posts</h6></li>
1010
<li><a class="dropdown-item" href="create">Create new</a></li>
1111
<li><a class="dropdown-item" href="draft">Show drafts</a></li>
12+
<li><a class="dropdown-item" href="settings">Show settings</a></li>
1213
<li><hr class="dropdown-divider"></li>
1314
<li><h6 class="dropdown-header">Analytics</h6></li>
1415
<li><a class="dropdown-item" href="dashboard">Dashboard</a></li>

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
@using LinkDotNet.Blog.Infrastructure
66
@using LinkDotNet.Blog.Infrastructure.Persistence
77
@using LinkDotNet.Blog.Web.Features.Home.Components
8+
@using LinkDotNet.Blog.Web.Features.Services
89
@using Microsoft.Extensions.Caching.Memory
10+
@using Microsoft.Extensions.Primitives
911
@inject IMemoryCache MemoryCache
12+
@inject ICacheTokenProvider CacheTokenProvider
1013
@inject IRepository<BlogPost> BlogPostRepository
1114
@inject IOptions<Introduction> Introduction
1215
@inject IOptions<ApplicationConfiguration> AppConfiguration
@@ -61,7 +64,9 @@
6164
{
6265
currentPage = await MemoryCache.GetOrCreateAsync(firstPageCacheKey, async entry =>
6366
{
64-
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
67+
var cacheDuration = TimeSpan.FromMinutes(AppConfiguration.Value.FirstPageCacheDurationInMinutes);
68+
entry.AbsoluteExpirationRelativeToNow = cacheDuration;
69+
entry.AddExpirationToken(new CancellationChangeToken(CacheTokenProvider.Token));
6570
return await GetAllForPageAsync(1);
6671
});
6772
return;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
using System.Threading;
3+
4+
namespace LinkDotNet.Blog.Web.Features.Services;
5+
6+
public sealed class CacheService : ICacheTokenProvider, ICacheInvalidator, IDisposable
7+
{
8+
private CancellationTokenSource cancellationTokenSource = new();
9+
10+
public CancellationToken Token => cancellationTokenSource.Token;
11+
12+
public void Cancel()
13+
{
14+
cancellationTokenSource.Cancel();
15+
cancellationTokenSource = new();
16+
}
17+
18+
public void Dispose() => cancellationTokenSource.Dispose();
19+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace LinkDotNet.Blog.Web.Features.Services;
2+
3+
public interface ICacheInvalidator
4+
{
5+
void Cancel();
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using System.Threading;
2+
3+
namespace LinkDotNet.Blog.Web.Features.Services;
4+
5+
public interface ICacheTokenProvider
6+
{
7+
CancellationToken Token { get; }
8+
}

src/LinkDotNet.Blog.Web/ServiceExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,9 @@ public static void RegisterServices(this IServiceCollection services)
1515
services.AddScoped<ISitemapService, SitemapService>();
1616
services.AddScoped<IXmlFileWriter, XmlFileWriter>();
1717
services.AddScoped<IFileProcessor, FileProcessor>();
18+
19+
services.AddSingleton<CacheService>();
20+
services.AddSingleton<ICacheTokenProvider>(s => s.GetRequiredService<CacheService>());
21+
services.AddSingleton<ICacheInvalidator>(s => s.GetRequiredService<CacheService>());
1822
}
1923
}

src/LinkDotNet.Blog.Web/appsettings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"ClientSecret": ""
3030
},
3131
"BlogPostsPerPage": 10,
32+
"FirstPageCacheDurationInMinutes": 10,
3233
"ProfileInformation": {
3334
"Name": "Steven Giesel",
3435
"Heading": "Software Engineer",

tests/LinkDotNet.Blog.IntegrationTests/Web/Features/BlogPostPublisherTests.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using LinkDotNet.Blog.Domain;
55
using LinkDotNet.Blog.TestUtilities;
66
using LinkDotNet.Blog.Web.Features;
7+
using LinkDotNet.Blog.Web.Features.Services;
78
using Microsoft.Extensions.DependencyInjection;
89
using Microsoft.Extensions.Logging;
910

@@ -12,14 +13,17 @@ namespace LinkDotNet.Blog.IntegrationTests.Web.Features;
1213
public sealed class BlogPostPublisherTests : SqlDatabaseTestBase<BlogPost>, IDisposable
1314
{
1415
private readonly BlogPostPublisher sut;
16+
private readonly ICacheInvalidator cacheInvalidator;
1517

1618
public BlogPostPublisherTests()
1719
{
1820
var serviceProvider = new ServiceCollection()
1921
.AddScoped(_ => Repository)
2022
.BuildServiceProvider();
23+
24+
cacheInvalidator = Substitute.For<ICacheInvalidator>();
2125

22-
sut = new BlogPostPublisher(serviceProvider, Substitute.For<ILogger<BlogPostPublisher>>());
26+
sut = new BlogPostPublisher(serviceProvider, cacheInvalidator, Substitute.For<ILogger<BlogPostPublisher>>());
2327
}
2428

2529
[Fact]
@@ -39,6 +43,26 @@ public async Task ShouldPublishScheduledBlogPosts()
3943
(await Repository.GetByIdAsync(bp2.Id)).IsPublished.Should().BeTrue();
4044
(await Repository.GetByIdAsync(bp3.Id)).IsPublished.Should().BeFalse();
4145
}
46+
47+
[Fact]
48+
public async Task ShouldInvalidateCacheWhenPublishing()
49+
{
50+
var now = DateTime.Now;
51+
var bp1 = new BlogPostBuilder().WithScheduledPublishDate(now.AddHours(-3)).IsPublished(false).Build();
52+
await Repository.StoreAsync(bp1);
53+
54+
await sut.StartAsync(CancellationToken.None);
55+
56+
cacheInvalidator.Received().Cancel();
57+
}
58+
59+
[Fact]
60+
public async Task ShouldNotInvalidateCacheWhenThereIsNothingToPublish()
61+
{
62+
await sut.StartAsync(CancellationToken.None);
63+
64+
cacheInvalidator.DidNotReceive().Cancel();
65+
}
4266

4367
public void Dispose() => sut?.Dispose();
4468
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,5 +167,6 @@ private void RegisterComponents(TestContextBase ctx, string profilePictureUri =
167167
ctx.Services.AddScoped(_ => Repository);
168168
ctx.Services.AddScoped(_ => Options.Create(CreateSampleAppConfiguration(profilePictureUri).ApplicationConfiguration));
169169
ctx.Services.AddScoped(_ => Options.Create(CreateSampleAppConfiguration(profilePictureUri).Introduction));
170+
ctx.Services.AddScoped(_ => Substitute.For<ICacheTokenProvider>());
170171
}
171172
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using Blazored.Toast.Services;
2+
using LinkDotNet.Blog.Web;
3+
using LinkDotNet.Blog.Web.Features.Admin.Settings;
4+
using LinkDotNet.Blog.Web.Features.Services;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace LinkDotNet.Blog.UnitTests.Web.Features.Admin.Settings;
9+
10+
public class SettingsPageTests : TestContext
11+
{
12+
[Fact]
13+
public void GivenSettingsPage_WhenClicking_InvalidateCacheButton_TokenIsCancelled()
14+
{
15+
var cacheInvalidator = Substitute.For<ICacheInvalidator>();
16+
Services.AddScoped(_ => cacheInvalidator);
17+
Services.AddScoped(_ => Options.Create<ApplicationConfiguration>(new()));
18+
Services.AddScoped(_ => Substitute.For<IToastService>());
19+
var cut = RenderComponent<SettingsPage>();
20+
var invalidateCacheButton = cut.Find("#invalidate-cache");
21+
22+
invalidateCacheButton.Click();
23+
24+
cacheInvalidator.Received(1).Cancel();
25+
}
26+
}

0 commit comments

Comments
 (0)