-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathContentCache.cs
More file actions
184 lines (163 loc) · 7.23 KB
/
Copy pathContentCache.cs
File metadata and controls
184 lines (163 loc) · 7.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using StackExchange.Redis;
namespace PortfolioCMS.Core.Services;
/// <summary>
/// Redis-backed read cache for public content. Falls back to the factory (database)
/// whenever Redis is unreachable — the cache must never take the site down.
/// </summary>
public class ContentCache(IDistributedCache cache, IConnectionMultiplexer redis, ILogger<ContentCache> logger)
{
/// <summary>
/// Global Redis key prefix. Shared with <c>RedisCacheOptions.InstanceName</c> so the
/// prefix used for writes and the one used by <see cref="FlushAllAsync"/> cannot drift apart.
/// </summary>
public const string InstancePrefix = "portfoliocms:v2:";
/// <summary>
/// The generated sitemap. Derived from the works, skill groups and experiences, so it is
/// invalidated by <see cref="InvalidateSectionAsync"/> together with the section it came from —
/// a new project shows up in it without anyone having to remember this key.
/// </summary>
public const string SitemapKey = "sitemap";
/// <summary>
/// The slug lists the SPA fallback answers 200 or 404 from. Derived from the same three
/// sections as the sitemap and dropped with them, so a new project stops being a 404 as soon as
/// it is saved.
/// </summary>
public const string RoutesKey = "routes";
/// <summary>
/// The per-page title, description and preview image the SPA fallback writes into the served
/// <c>index.html</c>. Derived from the same three sections plus the about record, so it is
/// dropped both here and by <see cref="InvalidateAboutAsync"/> — a renamed project has the
/// right link preview on the next request.
/// </summary>
public const string MetaKey = "meta";
/// <summary>
/// Portfolio content changes rarely and every admin mutation invalidates its keys explicitly,
/// so the TTL is only a safety net against a missed invalidation.
/// </summary>
private static readonly TimeSpan DefaultTtl = TimeSpan.FromDays(1);
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
/// <param name="shouldCache">
/// Optional gate on the freshly loaded value. Runs only for non-null values; when it returns
/// false the value is served but not written to Redis. <c>null</c> is never cached.
/// </param>
public async Task<T> GetOrSetAsync<T>(
string key, Func<Task<T>> factory, TimeSpan? ttl = null, Func<T, bool>? shouldCache = null)
{
try
{
var cached = await cache.GetStringAsync(key);
if (cached is not null)
{
logger.LogDebug("Cache hit for {CacheKey}", key);
var value = JsonSerializer.Deserialize<T>(cached, JsonOptions);
if (value is not null) return value;
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Cache read failed for {CacheKey}, falling back to database", key);
}
logger.LogDebug("Cache miss for {CacheKey}", key);
var fresh = await factory();
// Caching null would only store a "null" literal that the read path above rejects anyway,
// so a missing slug must not produce a Redis write on every request.
if (fresh is null || (shouldCache is not null && !shouldCache(fresh))) return fresh;
try
{
await cache.SetStringAsync(key, JsonSerializer.Serialize(fresh, JsonOptions),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl ?? DefaultTtl });
}
catch (Exception ex)
{
logger.LogWarning(ex, "Cache write failed for {CacheKey}", key);
}
return fresh;
}
/// <summary>
/// Removes the list key of a section, the affected slug keys, the sitemap, the public route map
/// and the page meta. Every content mutation already calls this, which is what keeps all three
/// derived values automatic.
/// </summary>
public async Task InvalidateSectionAsync(string section, params string?[] slugs)
{
var keys = new List<string> { $"{section}:list", SitemapKey, RoutesKey, MetaKey };
keys.AddRange(slugs.Where(s => !string.IsNullOrEmpty(s)).Select(s => $"{section}:slug:{s}"));
foreach (var key in keys)
{
try
{
await cache.RemoveAsync(key);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Cache invalidation failed for {CacheKey}", key);
}
}
logger.LogInformation("Cache invalidated for section {Section} ({KeyCount} keys)", section, keys.Count);
}
/// <summary>
/// The about record and the home page's head that is built from it (its name and bio, and the
/// profile picture that is the site's link preview).
/// </summary>
public async Task InvalidateAboutAsync()
{
await InvalidateKeyAsync("about");
await InvalidateKeyAsync(MetaKey);
}
/// <summary>
/// Removes a single slug key without touching the section list or the sitemap. Attachments are
/// the case for it: they have no list key and never appear in the sitemap, so the section-wide
/// invalidation would only throw away a sitemap that is still correct.
/// </summary>
public Task InvalidateSlugAsync(string section, string slug) =>
InvalidateKeyAsync($"{section}:slug:{slug}");
/// <summary>
/// Drops every key of this instance. Called on startup because migrations and seeding write
/// the database directly, without going through the invalidating admin endpoints.
/// </summary>
public async Task FlushAllAsync()
{
try
{
var db = redis.GetDatabase();
var removed = 0;
var scanned = 0;
foreach (var endpoint in redis.GetEndPoints())
{
var server = redis.GetServer(endpoint);
if (server.IsReplica || !server.IsConnected) continue;
scanned++;
await foreach (var key in server.KeysAsync(db.Database, $"{InstancePrefix}*"))
{
if (await db.KeyDeleteAsync(key)) removed++;
}
}
// Zero reachable servers would silently look like an empty cache, so say it out loud.
if (scanned == 0)
logger.LogWarning("Cache flush skipped: no reachable Redis server, the cache may be stale");
else
logger.LogInformation("Cache flushed on startup ({KeyCount} keys removed)", removed);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Cache flush failed, continuing with a possibly stale cache");
}
}
private async Task InvalidateKeyAsync(string key)
{
try
{
await cache.RemoveAsync(key);
logger.LogInformation("Cache invalidated for {CacheKey}", key);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Cache invalidation failed for {CacheKey}", key);
}
}
}