Skip to content

Commit 0d007f6

Browse files
committed
feat: integrate Redis caching for improved performance and content management
1 parent 02131b6 commit 0d007f6

11 files changed

Lines changed: 162 additions & 59 deletions

File tree

AppHost/AppHost.cs

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,31 @@
55

66
var builder = DistributedApplication.CreateBuilder(args);
77

8+
// Add Redis cache
9+
var redis = builder.AddRedis("redis")
10+
.WithRedisInsight()
11+
.WithLifetime(ContainerLifetime.Persistent)
12+
.PublishAsConnectionString();
13+
814
// NOTE: The type returned by AddAzureStorage is IResourceBuilder<AzureStorageResource>,
915
// but if storage is being cast or wrapped, ensure it is of the correct type for extension methods.
1016
var storage = builder.AddAzureStorage("azure-storage")
11-
.RunAsEmulator(options =>
12-
{
13-
// Configure the Azure Storage Emulator options here if needed
14-
options.WithLifetime(ContainerLifetime.Persistent);
15-
options.WithTablePort(27002);
16-
options.WithBlobPort(27001);
17-
options.WithQueuePort(27003);
18-
});
17+
.RunAsEmulator(options =>
18+
{
19+
// Configure the Azure Storage Emulator options here if needed
20+
options.WithLifetime(ContainerLifetime.Persistent);
21+
options.WithTablePort(27002);
22+
options.WithBlobPort(27001);
23+
options.WithQueuePort(27003);
24+
});
1925

2026
var tables = storage.AddTables("tables");
2127

2228
var web = builder.AddProject<Web>("web")
23-
.WithReference(tables)
24-
.WaitFor(tables)
25-
.WithExternalHttpEndpoints();
29+
.WithReference(tables)
30+
.WithReference(redis)
31+
.WaitFor(tables)
32+
.WaitFor(redis)
33+
.WithExternalHttpEndpoints();
2634

2735
builder.Build().Run();

AppHost/AppHost.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
<Nullable>enable</Nullable>
1010
<UserSecretsId>21fabb30-ac7d-4818-8766-92672e7b369a</UserSecretsId>
1111
</PropertyGroup>
12-
1312
<ItemGroup>
1413
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.0" />
1514
<PackageReference Include="Aspire.Hosting.Azure.AppContainers" Version="9.3.0" />
1615
<PackageReference Include="Aspire.Hosting.Azure.Storage" Version="9.3.0" />
16+
<PackageReference Include="Aspire.Hosting.Redis" Version="9.3.0" />
1717
</ItemGroup>
1818

1919
<ItemGroup>

AppHost/infra/web.tmpl.yaml

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,14 @@ properties:
1717
targetPort: {{ targetPortOrDefault 8080 }}
1818
transport: http
1919
allowInsecure: false
20-
customDomains:
21-
- name: copilotthatjawn.com
22-
bindingType: SniEnabled
23-
certificateId: /subscriptions/09153f92-3cbc-46f1-8872-1683749eda4b/resourceGroups/rg-copilotthatjawn/providers/Microsoft.App/managedEnvironments/cae-uanpydy4xv63a/managedCertificates/copilotthatjawn.com-cae-uanp-250608192822
24-
- name: www.copilotthatjawn.com
25-
bindingType: SniEnabled
26-
certificateId: /subscriptions/09153f92-3cbc-46f1-8872-1683749eda4b/resourceGroups/rg-copilotthatjawn/providers/Microsoft.App/managedEnvironments/cae-uanpydy4xv63a/managedCertificates/www.copilotthatjawn.com-cae-uanp-250608193349
2720
registries:
2821
- server: {{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
2922
identity: {{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}
3023
secrets:
24+
- name: connectionstrings--redis
25+
value: '{{ securedParameter "redis" }}'
3126
- name: connectionstrings--tables
3227
value: '{{ .Env.AZURE_STORAGE_TABLEENDPOINT }}'
33-
- name: cacherefresh--apikey
34-
value: '{{ .Env.CACHE_REFRESH_API_KEY }}'
3528
template:
3629
containers:
3730
- image: {{ .Image }}
@@ -49,10 +42,10 @@ properties:
4942
value: "true"
5043
- name: OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY
5144
value: in_memory
45+
- name: ConnectionStrings__redis
46+
secretRef: connectionstrings--redis
5247
- name: ConnectionStrings__tables
5348
secretRef: connectionstrings--tables
54-
- name: CacheRefresh__ApiKey
55-
secretRef: cacherefresh--apikey
5649
scale:
5750
minReplicas: 1
5851
tags:

Web/Pages/Tips/Details.cshtml.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
using Microsoft.AspNetCore.Mvc;
22
using Microsoft.AspNetCore.Mvc.RazorPages;
3+
using Microsoft.AspNetCore.OutputCaching;
34
using Shared;
45
using Web.Services;
56

67
namespace Web.Pages.Tips;
78

9+
[OutputCache(PolicyName = "TipsContent")]
810
public class DetailsModel : BasePageModel
911
{
1012
private readonly IContentService _contentService;
1113
private readonly ILogger<DetailsModel> _logger;
12-
13-
// Override cache duration for individual tips - cache for 1 hour since they change less frequently
14-
protected override int CacheDurationSeconds => 3600;
14+
// Override cache duration for individual tips - cache for 24 hours since tips are relatively static content
15+
protected override int CacheDurationSeconds => 86400; // 24 hours
1516

1617
public DetailsModel(IContentService contentService, ILogger<DetailsModel> logger)
1718
{

Web/Pages/Tips/Index.cshtml.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ public class IndexModel : BasePageModel
99
{
1010
private readonly IContentService _contentService;
1111
private readonly ILogger<IndexModel> _logger;
12-
13-
// Override cache duration for tips list - cache for 5 minutes
14-
protected override int CacheDurationSeconds => 300;
12+
// Override cache duration for tips list - cache for 1 hour since new tips are added infrequently
13+
protected override int CacheDurationSeconds => 3600; // 1 hour
1514

1615
public IndexModel(IContentService contentService, ILogger<IndexModel> logger)
1716
{

Web/Program.cs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414

1515
builder.AddAzureTableClient("tables");
1616

17+
// Add Redis distributed caching - manual configuration since extension doesn't exist
18+
19+
builder.AddRedisDistributedCache("redis");
20+
builder.AddRedisOutputCache("redis");
21+
22+
// Configure Redis key prefixes for better organization
23+
builder.Services.PostConfigure<Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions>(options =>
24+
{
25+
options.InstanceName = "CopilotThatJawn:";
26+
});
27+
1728
// Add WebOptimizer services
1829
builder.Services.AddWebOptimizer(pipeline =>
1930
{
@@ -57,28 +68,40 @@
5768
// Add caching services
5869
builder.Services.AddResponseCaching();
5970
builder.Services.AddMemoryCache();
60-
builder.Services.AddOutputCache(options =>
71+
72+
// Configure output cache policies (Redis is already configured above via AddRedisOutputCache)
73+
builder.Services.Configure<Microsoft.AspNetCore.OutputCaching.OutputCacheOptions>(options =>
6174
{
6275
// Default site-wide caching policy
6376
options.AddBasePolicy(builder =>
6477
builder.Cache()
6578
.SetVaryByHost(true)
6679
.SetVaryByQuery("*")
6780
.SetVaryByHeader("Accept-Language") // Vary by language
68-
.Expire(TimeSpan.FromMinutes(10))); // Cache for 10 minutes by default
69-
70-
// Special policy for static content pages
81+
.Expire(TimeSpan.FromMinutes(10)) // Cache for 10 minutes by default
82+
.Tag("outputcache", "site")); // Add tags for better organization
83+
// Special policy for static content pages
7184
options.AddPolicy("StaticContent", builder =>
7285
builder.Cache()
7386
.SetVaryByHost(true)
74-
.Expire(TimeSpan.FromHours(1))); // Cache static content for 1 hour
87+
.Expire(TimeSpan.FromHours(1)) // Cache static content for 1 hour
88+
.Tag("outputcache", "static")); // Add tags for better organization
89+
90+
// Special policy for tips and content pages - cache for 24 hours since they're relatively static
91+
options.AddPolicy("TipsContent", builder =>
92+
builder.Cache()
93+
.SetVaryByHost(true)
94+
.SetVaryByRouteValue("slug") // Vary by tip slug
95+
.Expire(TimeSpan.FromHours(24)) // Cache tips for 24 hours
96+
.Tag("outputcache", "tips", "content")); // Add tags for better organization
7597

7698
// Policy for frequently updated content
7799
options.AddPolicy("DynamicContent", builder =>
78100
builder.Cache()
79101
.SetVaryByHost(true)
80102
.SetVaryByQuery("*")
81-
.Expire(TimeSpan.FromMinutes(5))); // Cache dynamic content for 5 minutes
103+
.Expire(TimeSpan.FromMinutes(5)) // Cache dynamic content for 5 minutes
104+
.Tag("outputcache", "dynamic")); // Add tags for better organization
82105
});
83106

84107
// Add response compression
@@ -127,6 +150,9 @@
127150
app.UseDeveloperExceptionPage();
128151
}
129152

153+
// Enable output cache in all environments to test Redis
154+
// app.UseOutputCache();
155+
130156
// Enable compression and caching early in the pipeline
131157
app.UseHttpsRedirection();
132158

Web/Services/ContentService.cs

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,44 @@
11
using Markdig;
22
using Microsoft.Extensions.Caching.Memory;
3+
using Microsoft.Extensions.Caching.Distributed;
4+
using Microsoft.AspNetCore.OutputCaching;
35
using System.Text.RegularExpressions;
46
using YamlDotNet.Serialization;
57
using YamlDotNet.Serialization.NamingConventions;
68
using Azure.Data.Tables;
79
using Shared;
10+
using System.Text.Json;
811

912
namespace Web.Services;
1013

1114
/// <summary>
12-
/// Service for managing markdown-based content
15+
/// Service for managing markdown-based content with Redis distributed caching
1316
/// </summary>
1417
public class ContentService : IContentService
15-
{
16-
private readonly ILogger<ContentService> _logger;
18+
{ private readonly ILogger<ContentService> _logger;
1719
private readonly IWebHostEnvironment _environment;
18-
private readonly IMemoryCache _cache;
20+
private readonly IMemoryCache _cache; // Keep for very short-term local caching
21+
private readonly IDistributedCache _distributedCache; // Redis cache
22+
private readonly IOutputCacheStore _outputCacheStore; // Output cache store for invalidation
1923
private readonly MarkdownPipeline _markdownPipeline;
2024
private readonly IDeserializer _yamlDeserializer;
2125
private readonly TableClient _tableClient;
2226
private const string TIPS_CACHE_KEY = "content_tips";
23-
private static readonly TimeSpan _cacheExpiry = TimeSpan.FromHours(6);
27+
private static readonly TimeSpan _distributedCacheExpiry = TimeSpan.FromHours(6);
28+
private static readonly TimeSpan _localCacheExpiry = TimeSpan.FromMinutes(5); // Short local cache for frequently accessed data
2429

2530
public ContentService(
2631
ILogger<ContentService> logger,
2732
IWebHostEnvironment environment,
2833
IMemoryCache cache,
29-
TableServiceClient tableServiceClient)
30-
{
34+
IDistributedCache distributedCache,
35+
IOutputCacheStore outputCacheStore,
36+
TableServiceClient tableServiceClient) {
3137
_logger = logger;
3238
_environment = environment;
3339
_cache = cache;
40+
_distributedCache = distributedCache;
41+
_outputCacheStore = outputCacheStore;
3442

3543
// Configure Markdig without syntax highlighting (using Prism.js client-side instead)
3644
_markdownPipeline = new MarkdownPipelineBuilder()
@@ -164,25 +172,32 @@ public async Task<List<TipModel>> GetRelatedTipsAsync(TipModel tip, int count =
164172
.ToList();
165173

166174
return relatedTips;
167-
}
168-
169-
public async Task RefreshContentAsync()
175+
} public async Task RefreshContentAsync()
170176
{
171177
_logger.LogInformation("Refreshing content cache...");
172178

173179
try
174180
{
175181
var tips = await GetTipsFromAzureTableAsync();
176182

177-
var cacheEntryOptions = new MemoryCacheEntryOptions()
178-
.SetSlidingExpiration(_cacheExpiry)
183+
// Update Redis cache
184+
var serializedTips = JsonSerializer.Serialize(tips);
185+
var distributedCacheOptions = new DistributedCacheEntryOptions
186+
{
187+
SlidingExpiration = _distributedCacheExpiry
188+
};
189+
await _distributedCache.SetStringAsync(TIPS_CACHE_KEY, serializedTips, distributedCacheOptions);
190+
191+
// Update local cache
192+
var localCacheEntryOptions = new MemoryCacheEntryOptions()
193+
.SetSlidingExpiration(_localCacheExpiry)
179194
.SetPriority(CacheItemPriority.Normal)
180195
.RegisterPostEvictionCallback((key, value, reason, state) =>
181196
{
182-
_logger.LogInformation("Content cache evicted. Reason: {Reason}", reason);
197+
_logger.LogDebug("Local content cache evicted. Reason: {Reason}", reason);
183198
});
184199

185-
_cache.Set(TIPS_CACHE_KEY, tips, cacheEntryOptions);
200+
_cache.Set(TIPS_CACHE_KEY, tips, localCacheEntryOptions);
186201
_logger.LogInformation("Content cache refreshed. Loaded {Count} tips", tips.Count);
187202
}
188203
catch (Exception ex)
@@ -227,17 +242,48 @@ private async Task<List<TipModel>> GetTipsFromAzureTableAsync()
227242
}
228243

229244
return tips;
230-
}
231-
232-
private async Task<List<TipModel>> GetTipsFromCacheAsync()
245+
} private async Task<List<TipModel>> GetTipsFromCacheAsync()
233246
{
234-
if (_cache.TryGetValue(TIPS_CACHE_KEY, out List<TipModel>? tips))
247+
// First, check local memory cache for very recent data
248+
if (_cache.TryGetValue(TIPS_CACHE_KEY, out List<TipModel>? localTips))
249+
{
250+
return localTips ?? new List<TipModel>();
251+
}
252+
253+
// Check Redis distributed cache
254+
var distributedTipsJson = await _distributedCache.GetStringAsync(TIPS_CACHE_KEY);
255+
List<TipModel> tips;
256+
257+
if (!string.IsNullOrEmpty(distributedTipsJson))
258+
{
259+
try
260+
{
261+
tips = JsonSerializer.Deserialize<List<TipModel>>(distributedTipsJson) ?? new List<TipModel>();
262+
_logger.LogDebug("Loaded {Count} tips from Redis cache", tips.Count);
263+
}
264+
catch (JsonException ex)
265+
{
266+
_logger.LogWarning(ex, "Failed to deserialize tips from Redis cache, falling back to database");
267+
tips = await GetTipsFromAzureTableAsync();
268+
}
269+
}
270+
else
235271
{
236-
return tips ?? new List<TipModel>();
272+
// Cache miss - load from database
273+
tips = await GetTipsFromAzureTableAsync();
274+
275+
// Store in Redis
276+
var serializedTips = JsonSerializer.Serialize(tips);
277+
var distributedCacheOptions = new DistributedCacheEntryOptions
278+
{
279+
SlidingExpiration = _distributedCacheExpiry
280+
};
281+
await _distributedCache.SetStringAsync(TIPS_CACHE_KEY, serializedTips, distributedCacheOptions);
282+
_logger.LogInformation("Cached {Count} tips in Redis", tips.Count);
237283
}
238284

239-
tips = await GetTipsFromAzureTableAsync();
240-
_cache.Set(TIPS_CACHE_KEY, tips, _cacheExpiry);
285+
// Store in local memory cache for very short term
286+
_cache.Set(TIPS_CACHE_KEY, tips, _localCacheExpiry);
241287

242288
return tips;
243289
}
@@ -326,7 +372,29 @@ private string PostProcessCodeBlocks(string htmlContent)
326372
var pattern1 = @"<pre><code class=""language-(\w+)"">";
327373
var replacement1 = @"<pre class=""language-$1""><code class=""language-$1"">";
328374
htmlContent = Regex.Replace(htmlContent, pattern1, replacement1);
329-
330375
return htmlContent;
331-
}
376+
} /// <summary>
377+
/// Invalidate all tips-related cache entries
378+
/// </summary>
379+
public async Task InvalidateTipsCacheAsync()
380+
{
381+
try
382+
{
383+
// Clear distributed cache
384+
await _distributedCache.RemoveAsync(TIPS_CACHE_KEY);
385+
386+
// Clear local memory cache
387+
_cache.Remove(TIPS_CACHE_KEY);
388+
389+
// Clear output cache for tips pages
390+
await _outputCacheStore.EvictByTagAsync("tips", default);
391+
await _outputCacheStore.EvictByTagAsync("content", default);
392+
393+
_logger.LogInformation("Tips cache invalidated successfully");
394+
}
395+
catch (Exception ex)
396+
{
397+
_logger.LogError(ex, "Error invalidating tips cache");
398+
}
399+
}
332400
}

Web/Services/IContentService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ public interface IContentService
1414
Task<List<string>> GetTagsAsync();
1515
Task<List<TipModel>> GetRelatedTipsAsync(TipModel tip, int count = 3);
1616
Task RefreshContentAsync();
17+
Task InvalidateTipsCacheAsync();
1718
}

0 commit comments

Comments
 (0)