|
1 | 1 | using Markdig; |
2 | 2 | using Microsoft.Extensions.Caching.Memory; |
| 3 | +using Microsoft.Extensions.Caching.Distributed; |
| 4 | +using Microsoft.AspNetCore.OutputCaching; |
3 | 5 | using System.Text.RegularExpressions; |
4 | 6 | using YamlDotNet.Serialization; |
5 | 7 | using YamlDotNet.Serialization.NamingConventions; |
6 | 8 | using Azure.Data.Tables; |
7 | 9 | using Shared; |
| 10 | +using System.Text.Json; |
8 | 11 |
|
9 | 12 | namespace Web.Services; |
10 | 13 |
|
11 | 14 | /// <summary> |
12 | | -/// Service for managing markdown-based content |
| 15 | +/// Service for managing markdown-based content with Redis distributed caching |
13 | 16 | /// </summary> |
14 | 17 | public class ContentService : IContentService |
15 | | -{ |
16 | | - private readonly ILogger<ContentService> _logger; |
| 18 | +{ private readonly ILogger<ContentService> _logger; |
17 | 19 | 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 |
19 | 23 | private readonly MarkdownPipeline _markdownPipeline; |
20 | 24 | private readonly IDeserializer _yamlDeserializer; |
21 | 25 | private readonly TableClient _tableClient; |
22 | 26 | 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 |
24 | 29 |
|
25 | 30 | public ContentService( |
26 | 31 | ILogger<ContentService> logger, |
27 | 32 | IWebHostEnvironment environment, |
28 | 33 | IMemoryCache cache, |
29 | | - TableServiceClient tableServiceClient) |
30 | | - { |
| 34 | + IDistributedCache distributedCache, |
| 35 | + IOutputCacheStore outputCacheStore, |
| 36 | + TableServiceClient tableServiceClient) { |
31 | 37 | _logger = logger; |
32 | 38 | _environment = environment; |
33 | 39 | _cache = cache; |
| 40 | + _distributedCache = distributedCache; |
| 41 | + _outputCacheStore = outputCacheStore; |
34 | 42 |
|
35 | 43 | // Configure Markdig without syntax highlighting (using Prism.js client-side instead) |
36 | 44 | _markdownPipeline = new MarkdownPipelineBuilder() |
@@ -164,25 +172,32 @@ public async Task<List<TipModel>> GetRelatedTipsAsync(TipModel tip, int count = |
164 | 172 | .ToList(); |
165 | 173 |
|
166 | 174 | return relatedTips; |
167 | | - } |
168 | | - |
169 | | - public async Task RefreshContentAsync() |
| 175 | + } public async Task RefreshContentAsync() |
170 | 176 | { |
171 | 177 | _logger.LogInformation("Refreshing content cache..."); |
172 | 178 |
|
173 | 179 | try |
174 | 180 | { |
175 | 181 | var tips = await GetTipsFromAzureTableAsync(); |
176 | 182 |
|
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) |
179 | 194 | .SetPriority(CacheItemPriority.Normal) |
180 | 195 | .RegisterPostEvictionCallback((key, value, reason, state) => |
181 | 196 | { |
182 | | - _logger.LogInformation("Content cache evicted. Reason: {Reason}", reason); |
| 197 | + _logger.LogDebug("Local content cache evicted. Reason: {Reason}", reason); |
183 | 198 | }); |
184 | 199 |
|
185 | | - _cache.Set(TIPS_CACHE_KEY, tips, cacheEntryOptions); |
| 200 | + _cache.Set(TIPS_CACHE_KEY, tips, localCacheEntryOptions); |
186 | 201 | _logger.LogInformation("Content cache refreshed. Loaded {Count} tips", tips.Count); |
187 | 202 | } |
188 | 203 | catch (Exception ex) |
@@ -227,17 +242,48 @@ private async Task<List<TipModel>> GetTipsFromAzureTableAsync() |
227 | 242 | } |
228 | 243 |
|
229 | 244 | return tips; |
230 | | - } |
231 | | - |
232 | | - private async Task<List<TipModel>> GetTipsFromCacheAsync() |
| 245 | + } private async Task<List<TipModel>> GetTipsFromCacheAsync() |
233 | 246 | { |
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 |
235 | 271 | { |
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); |
237 | 283 | } |
238 | 284 |
|
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); |
241 | 287 |
|
242 | 288 | return tips; |
243 | 289 | } |
@@ -326,7 +372,29 @@ private string PostProcessCodeBlocks(string htmlContent) |
326 | 372 | var pattern1 = @"<pre><code class=""language-(\w+)"">"; |
327 | 373 | var replacement1 = @"<pre class=""language-$1""><code class=""language-$1"">"; |
328 | 374 | htmlContent = Regex.Replace(htmlContent, pattern1, replacement1); |
329 | | - |
330 | 375 | 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 | + } |
332 | 400 | } |
0 commit comments