Merge pull request 'Bound how long the blog cache serves stale entries' (#7) from fix/blog-cache-staleness into master
Build and Deploy / build-deploy (push) Successful in 1m4s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-26 17:24:29 -06:00
@@ -1,4 +1,4 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using JoshHeaps.Net.Models; using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
@@ -10,6 +10,7 @@ public class BlogService : IBlogService
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger; private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5); private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(); private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
@@ -65,7 +66,9 @@ public class BlogService : IBlogService
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{ {
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow) _cache.TryGetValue(key, out var entry);
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value; return (T?)entry.Value;
var result = await factory(); var result = await factory();
@@ -73,15 +76,26 @@ public class BlogService : IBlogService
if (result is not null) if (result is not null)
{ {
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl)); _cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
} }
else if (entry is not null)
return entry is null ? null : ServeStale<T>(key, entry);
}
// Stale entries are dropped once the grace window closes so that a dead API fails the same
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
// the posts themselves, which reads as a site bug rather than an outage.
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
{
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{ {
// API unreachable serve stale cache _logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
_logger.LogWarning("Serving stale cache for key {Key}", key);
return (T?)entry.Value; return (T?)entry.Value;
} }
return result; _cache.TryRemove(key, out _);
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
return null;
} }
public void ClearCache() public void ClearCache()