From 9510b710ffce8e2302fdb88777f06e3e4a33a823 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:11:27 +0000 Subject: [PATCH 1/5] Initial plan From 0aa84763a27509e4f2f8b613c952b34a0404e5c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:15:22 +0000 Subject: [PATCH 2/5] feat: add semantic cache keys for hackathon hot queries Co-authored-by: qin-guan <10321883+qin-guan@users.noreply.github.com> --- CACHING.md | 2 +- HackOMania.Api/Services/SqlSugarRedisCache.cs | 60 +++++++++++++++++-- .../Services/SqlSugarRedisCacheKeyTests.cs | 35 +++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs diff --git a/CACHING.md b/CACHING.md index 4e28daf1..7b5f13fb 100644 --- a/CACHING.md +++ b/CACHING.md @@ -12,7 +12,7 @@ The HackOMania Event Platform uses a dual-layer caching system to improve perfor - Implementation: `SqlSugarRedisCache` service - Uses StackExchange.Redis via Aspire integration - JSON serialization for cache entries - - Keys follow pattern: `SqlSugarDataCache.*` + - Keys follow pattern: `SqlSugarDataCache.{semantic-segment}.*` for hot hackathon queries (fallback remains `SqlSugarDataCache.*`) 2. **Fallback Cache: NoOpCacheService** - Activated when Redis is unavailable during startup diff --git a/HackOMania.Api/Services/SqlSugarRedisCache.cs b/HackOMania.Api/Services/SqlSugarRedisCache.cs index df73694c..c8cc929a 100644 --- a/HackOMania.Api/Services/SqlSugarRedisCache.cs +++ b/HackOMania.Api/Services/SqlSugarRedisCache.cs @@ -6,6 +6,7 @@ namespace HackOMania.Api.Services; public class SqlSugarRedisCache(IConnectionMultiplexer connectionMultiplexer) : ICacheService { + private const string SqlSugarCachePrefix = "SqlSugarDataCache."; private readonly IDatabase _db = connectionMultiplexer.GetDatabase(); public void Add(string key, TV value) @@ -13,7 +14,7 @@ public void Add(string key, TV value) if (value != null) { var json = JsonSerializer.Serialize(value); - _db.StringSet(key, json); + _db.StringSet(ToSemanticCacheKey(key), json); } } @@ -22,18 +23,25 @@ public void Add(string key, TV value, int cacheDurationInSeconds) if (value != null) { var json = JsonSerializer.Serialize(value); - _db.StringSet(key, json, TimeSpan.FromSeconds(cacheDurationInSeconds)); + _db.StringSet(ToSemanticCacheKey(key), json, TimeSpan.FromSeconds(cacheDurationInSeconds)); } } public bool ContainsKey(string key) { - return _db.KeyExists(key); + var semanticKey = ToSemanticCacheKey(key); + return _db.KeyExists(semanticKey) || _db.KeyExists(key); } public TV Get(string key) { - var value = _db.StringGet(key); + var semanticKey = ToSemanticCacheKey(key); + var value = _db.StringGet(semanticKey); + if (value.IsNullOrEmpty && semanticKey != key) + { + value = _db.StringGet(key); + } + if (value.IsNullOrEmpty) { return default!; @@ -51,7 +59,7 @@ public IEnumerable GetAllKey() return []; } - return server.Keys(pattern: "SqlSugarDataCache.*").Select(k => k.ToString()); + return server.Keys(pattern: $"{SqlSugarCachePrefix}*").Select(k => k.ToString()); } public TV GetOrCreate( @@ -80,6 +88,46 @@ public TV GetOrCreate( public void Remove(string key) { - _db.KeyDelete(key); + var semanticKey = ToSemanticCacheKey(key); + _db.KeyDelete(semanticKey); + if (semanticKey != key) + { + _db.KeyDelete(key); + } + } + + public static string ToSemanticCacheKey(string key) + { + if (!key.StartsWith(SqlSugarCachePrefix, StringComparison.Ordinal)) + { + return key; + } + + var semanticSegment = GetSemanticSegment(key); + if (string.IsNullOrEmpty(semanticSegment)) + { + return key; + } + + return $"{SqlSugarCachePrefix}{semanticSegment}.{key[SqlSugarCachePrefix.Length..]}"; + } + + private static string? GetSemanticSegment(string key) + { + if (key.Contains("`Hackathon`", StringComparison.OrdinalIgnoreCase)) + { + if ( + key.Contains(" where ", StringComparison.OrdinalIgnoreCase) + || key.Contains(".`Id`", StringComparison.OrdinalIgnoreCase) + || key.Contains("ShortCode", StringComparison.OrdinalIgnoreCase) + ) + { + return "hackathon-details"; + } + + return "hackathon-list"; + } + + return null; } } diff --git a/HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs b/HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs new file mode 100644 index 00000000..dace5eba --- /dev/null +++ b/HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs @@ -0,0 +1,35 @@ +using HackOMania.Api.Services; + +namespace HackOMania.Tests.Services; + +public class SqlSugarRedisCacheKeyTests +{ + [Test] + public async Task ToSemanticCacheKey_HackathonDetailsKey_AddsSemanticPrefix() + { + // Arrange + var rawKey = + "SqlSugarDataCache.SELECT * FROM `Hackathon` h WHERE h.`Id` = @HackathonId LIMIT 1"; + + // Act + var semanticKey = SqlSugarRedisCache.ToSemanticCacheKey(rawKey); + + // Assert + await Assert + .That(semanticKey) + .StartsWith("SqlSugarDataCache.hackathon-details.", StringComparison.Ordinal); + } + + [Test] + public async Task ToSemanticCacheKey_NonSqlSugarKey_ReturnsOriginalKey() + { + // Arrange + var rawKey = "custom-cache-key"; + + // Act + var semanticKey = SqlSugarRedisCache.ToSemanticCacheKey(rawKey); + + // Assert + await Assert.That(semanticKey).IsEqualTo(rawKey); + } +} From 4acc4fec3be00455edc577285400f5810b7fa38d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:20:12 +0000 Subject: [PATCH 3/5] test: cover semantic cache key mapping behavior Co-authored-by: qin-guan <10321883+qin-guan@users.noreply.github.com> --- HackOMania.Api/Services/SqlSugarRedisCache.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/HackOMania.Api/Services/SqlSugarRedisCache.cs b/HackOMania.Api/Services/SqlSugarRedisCache.cs index c8cc929a..84ed5c75 100644 --- a/HackOMania.Api/Services/SqlSugarRedisCache.cs +++ b/HackOMania.Api/Services/SqlSugarRedisCache.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using SqlSugar; using StackExchange.Redis; @@ -7,6 +8,10 @@ namespace HackOMania.Api.Services; public class SqlSugarRedisCache(IConnectionMultiplexer connectionMultiplexer) : ICacheService { private const string SqlSugarCachePrefix = "SqlSugarDataCache."; + private static readonly Regex WhereClauseRegex = new( + @"\bWHERE\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled + ); private readonly IDatabase _db = connectionMultiplexer.GetDatabase(); public void Add(string key, TV value) @@ -114,13 +119,9 @@ public static string ToSemanticCacheKey(string key) private static string? GetSemanticSegment(string key) { - if (key.Contains("`Hackathon`", StringComparison.OrdinalIgnoreCase)) + if (key.Contains("Hackathon", StringComparison.OrdinalIgnoreCase)) { - if ( - key.Contains(" where ", StringComparison.OrdinalIgnoreCase) - || key.Contains(".`Id`", StringComparison.OrdinalIgnoreCase) - || key.Contains("ShortCode", StringComparison.OrdinalIgnoreCase) - ) + if (WhereClauseRegex.IsMatch(key)) { return "hackathon-details"; } From 46bf35982e5b02f05938afe68acdcbe68c304dd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:56:03 +0000 Subject: [PATCH 4/5] refactor: use explicit SqlSugar cache keys on hot hackathon endpoints Co-authored-by: qin-guan <10321883+qin-guan@users.noreply.github.com> --- CACHING.md | 4 +- .../Organizers/Hackathon/Get/Endpoint.cs | 5 +- .../Organizers/Hackathon/List/Endpoint.cs | 4 +- .../Participants/Hackathon/Get/Endpoint.cs | 3 +- .../Participants/Hackathon/List/Endpoint.cs | 2 +- HackOMania.Api/Services/SqlSugarRedisCache.cs | 61 ++----------------- .../Services/SqlSugarRedisCacheKeyTests.cs | 35 ----------- 7 files changed, 17 insertions(+), 97 deletions(-) delete mode 100644 HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs diff --git a/CACHING.md b/CACHING.md index 7b5f13fb..7616f048 100644 --- a/CACHING.md +++ b/CACHING.md @@ -12,7 +12,7 @@ The HackOMania Event Platform uses a dual-layer caching system to improve perfor - Implementation: `SqlSugarRedisCache` service - Uses StackExchange.Redis via Aspire integration - JSON serialization for cache entries - - Keys follow pattern: `SqlSugarDataCache.{semantic-segment}.*` for hot hackathon queries (fallback remains `SqlSugarDataCache.*`) + - Keys follow pattern: `SqlSugarDataCache.*` 2. **Fallback Cache: NoOpCacheService** - Activated when Redis is unavailable during startup @@ -179,6 +179,8 @@ SqlSugar automatically generates cache keys based on: - Query parameters - Table names +For hot hackathon endpoints, explicitly defined cache keys are used via `.WithCache("...")` (for example `hackathon:details:{id}` and `hackathon:public-list`) to make cache intent easier to understand while preserving table-based invalidation. + **User-Specific Data**: Queries with `WHERE userId = {userId}` get unique cache keys per user, providing natural isolation. **Navigation Properties**: Queries using `.Includes()` for navigation properties should **NOT** use `.WithCache()` as SqlSugar's caching doesn't properly serialize/deserialize navigation properties. Load related entities separately or use explicit joins instead. diff --git a/HackOMania.Api/Endpoints/Organizers/Hackathon/Get/Endpoint.cs b/HackOMania.Api/Endpoints/Organizers/Hackathon/Get/Endpoint.cs index f043d686..dceb7bd7 100644 --- a/HackOMania.Api/Endpoints/Organizers/Hackathon/Get/Endpoint.cs +++ b/HackOMania.Api/Endpoints/Organizers/Hackathon/Get/Endpoint.cs @@ -22,9 +22,10 @@ public override void Configure() public override async Task HandleAsync(Request req, CancellationToken ct) { + var hackathonCacheKey = $"hackathon:details:{req.HackathonId}"; var hackathon = await sql.Queryable() .Where(h => h.Id == req.HackathonId) - .WithCache() + .WithCache(hackathonCacheKey) .FirstAsync(ct); if (hackathon is null) @@ -35,7 +36,7 @@ public override async Task HandleAsync(Request req, CancellationToken ct) var emailTemplates = await sql.Queryable() .Where(t => t.HackathonId == hackathon.Id) - .WithCache() + .WithCache($"hackathon:details:{hackathon.Id}:notification-templates") .ToListAsync(ct); var emailTemplateMap = emailTemplates .GroupBy(t => t.EventKey, StringComparer.OrdinalIgnoreCase) diff --git a/HackOMania.Api/Endpoints/Organizers/Hackathon/List/Endpoint.cs b/HackOMania.Api/Endpoints/Organizers/Hackathon/List/Endpoint.cs index 2df0a029..abf78e59 100644 --- a/HackOMania.Api/Endpoints/Organizers/Hackathon/List/Endpoint.cs +++ b/HackOMania.Api/Endpoints/Organizers/Hackathon/List/Endpoint.cs @@ -41,12 +41,12 @@ public override async Task HandleAsync(CancellationToken ct) ); } - var hackathons = await query.WithCache().ToListAsync(ct); + var hackathons = await query.WithCache($"hackathon:organizer-list:{userId.Value}").ToListAsync(ct); var hackathonIds = hackathons.Select(h => h.Id).ToList(); var templates = await sql.Queryable() .Where(t => hackathonIds.Contains(t.HackathonId)) - .WithCache() + .WithCache($"hackathon:organizer-list:{userId.Value}:notification-templates") .ToListAsync(ct); var templatesByHackathon = templates .GroupBy(t => t.HackathonId) diff --git a/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs b/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs index bd87d413..48c85e4b 100644 --- a/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs +++ b/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs @@ -19,12 +19,13 @@ public override void Configure() public override async Task HandleAsync(Request req, CancellationToken ct) { + var hackathonCacheKey = $"hackathon:public-details:{req.HackathonIdOrShortCode}"; var hackathon = await sql.Queryable() .Where(h => h.Id.ToString() == req.HackathonIdOrShortCode || h.ShortCode == req.HackathonIdOrShortCode ) - .WithCache() + .WithCache(hackathonCacheKey) .FirstAsync(ct); if (hackathon is null || !hackathon.IsPublished) diff --git a/HackOMania.Api/Endpoints/Participants/Hackathon/List/Endpoint.cs b/HackOMania.Api/Endpoints/Participants/Hackathon/List/Endpoint.cs index 8cdd9a88..3a4409ed 100644 --- a/HackOMania.Api/Endpoints/Participants/Hackathon/List/Endpoint.cs +++ b/HackOMania.Api/Endpoints/Participants/Hackathon/List/Endpoint.cs @@ -21,7 +21,7 @@ public override async Task HandleAsync(CancellationToken ct) { var hackathons = await sql.Queryable() .Where(h => h.IsPublished) - .WithCache() + .WithCache("hackathon:public-list") .ToListAsync(ct); await Send.OkAsync( diff --git a/HackOMania.Api/Services/SqlSugarRedisCache.cs b/HackOMania.Api/Services/SqlSugarRedisCache.cs index 84ed5c75..df73694c 100644 --- a/HackOMania.Api/Services/SqlSugarRedisCache.cs +++ b/HackOMania.Api/Services/SqlSugarRedisCache.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; using SqlSugar; using StackExchange.Redis; @@ -7,11 +6,6 @@ namespace HackOMania.Api.Services; public class SqlSugarRedisCache(IConnectionMultiplexer connectionMultiplexer) : ICacheService { - private const string SqlSugarCachePrefix = "SqlSugarDataCache."; - private static readonly Regex WhereClauseRegex = new( - @"\bWHERE\b", - RegexOptions.IgnoreCase | RegexOptions.Compiled - ); private readonly IDatabase _db = connectionMultiplexer.GetDatabase(); public void Add(string key, TV value) @@ -19,7 +13,7 @@ public void Add(string key, TV value) if (value != null) { var json = JsonSerializer.Serialize(value); - _db.StringSet(ToSemanticCacheKey(key), json); + _db.StringSet(key, json); } } @@ -28,25 +22,18 @@ public void Add(string key, TV value, int cacheDurationInSeconds) if (value != null) { var json = JsonSerializer.Serialize(value); - _db.StringSet(ToSemanticCacheKey(key), json, TimeSpan.FromSeconds(cacheDurationInSeconds)); + _db.StringSet(key, json, TimeSpan.FromSeconds(cacheDurationInSeconds)); } } public bool ContainsKey(string key) { - var semanticKey = ToSemanticCacheKey(key); - return _db.KeyExists(semanticKey) || _db.KeyExists(key); + return _db.KeyExists(key); } public TV Get(string key) { - var semanticKey = ToSemanticCacheKey(key); - var value = _db.StringGet(semanticKey); - if (value.IsNullOrEmpty && semanticKey != key) - { - value = _db.StringGet(key); - } - + var value = _db.StringGet(key); if (value.IsNullOrEmpty) { return default!; @@ -64,7 +51,7 @@ public IEnumerable GetAllKey() return []; } - return server.Keys(pattern: $"{SqlSugarCachePrefix}*").Select(k => k.ToString()); + return server.Keys(pattern: "SqlSugarDataCache.*").Select(k => k.ToString()); } public TV GetOrCreate( @@ -93,42 +80,6 @@ public TV GetOrCreate( public void Remove(string key) { - var semanticKey = ToSemanticCacheKey(key); - _db.KeyDelete(semanticKey); - if (semanticKey != key) - { - _db.KeyDelete(key); - } - } - - public static string ToSemanticCacheKey(string key) - { - if (!key.StartsWith(SqlSugarCachePrefix, StringComparison.Ordinal)) - { - return key; - } - - var semanticSegment = GetSemanticSegment(key); - if (string.IsNullOrEmpty(semanticSegment)) - { - return key; - } - - return $"{SqlSugarCachePrefix}{semanticSegment}.{key[SqlSugarCachePrefix.Length..]}"; - } - - private static string? GetSemanticSegment(string key) - { - if (key.Contains("Hackathon", StringComparison.OrdinalIgnoreCase)) - { - if (WhereClauseRegex.IsMatch(key)) - { - return "hackathon-details"; - } - - return "hackathon-list"; - } - - return null; + _db.KeyDelete(key); } } diff --git a/HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs b/HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs deleted file mode 100644 index dace5eba..00000000 --- a/HackOMania.Tests/Services/SqlSugarRedisCacheKeyTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using HackOMania.Api.Services; - -namespace HackOMania.Tests.Services; - -public class SqlSugarRedisCacheKeyTests -{ - [Test] - public async Task ToSemanticCacheKey_HackathonDetailsKey_AddsSemanticPrefix() - { - // Arrange - var rawKey = - "SqlSugarDataCache.SELECT * FROM `Hackathon` h WHERE h.`Id` = @HackathonId LIMIT 1"; - - // Act - var semanticKey = SqlSugarRedisCache.ToSemanticCacheKey(rawKey); - - // Assert - await Assert - .That(semanticKey) - .StartsWith("SqlSugarDataCache.hackathon-details.", StringComparison.Ordinal); - } - - [Test] - public async Task ToSemanticCacheKey_NonSqlSugarKey_ReturnsOriginalKey() - { - // Arrange - var rawKey = "custom-cache-key"; - - // Act - var semanticKey = SqlSugarRedisCache.ToSemanticCacheKey(rawKey); - - // Assert - await Assert.That(semanticKey).IsEqualTo(rawKey); - } -} From ad1af76eab1213fd36054e9b50b048f68a7952fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:53:52 +0000 Subject: [PATCH 5/5] Normalize public-details cache key input for stable predictable keys Co-authored-by: qin-guan <10321883+qin-guan@users.noreply.github.com> --- CACHING.md | 2 +- .../Participants/Hackathon/Get/Endpoint.cs | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CACHING.md b/CACHING.md index 7616f048..d27c8c15 100644 --- a/CACHING.md +++ b/CACHING.md @@ -179,7 +179,7 @@ SqlSugar automatically generates cache keys based on: - Query parameters - Table names -For hot hackathon endpoints, explicitly defined cache keys are used via `.WithCache("...")` (for example `hackathon:details:{id}` and `hackathon:public-list`) to make cache intent easier to understand while preserving table-based invalidation. +For hot hackathon endpoints, explicitly defined cache keys are used via `.WithCache("...")` (for example `hackathon:details:{id}` and `hackathon:public-list`) to make cache intent easier to understand while preserving table-based invalidation. Public details keys also normalize the route token (`Guid` as `"D"` format, short code lowercased) so equivalent requests map to the same key. **User-Specific Data**: Queries with `WHERE userId = {userId}` get unique cache keys per user, providing natural isolation. diff --git a/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs b/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs index 48c85e4b..f760fe1a 100644 --- a/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs +++ b/HackOMania.Api/Endpoints/Participants/Hackathon/Get/Endpoint.cs @@ -19,11 +19,26 @@ public override void Configure() public override async Task HandleAsync(Request req, CancellationToken ct) { - var hackathonCacheKey = $"hackathon:public-details:{req.HackathonIdOrShortCode}"; + var requestedToken = (req.HackathonIdOrShortCode ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(requestedToken)) + { + await Send.NotFoundAsync(ct); + return; + } + var isGuidToken = Guid.TryParse(requestedToken, out var parsedHackathonId); + var normalizedShortCode = requestedToken.ToLower(); + var cacheKeySegment = isGuidToken + ? parsedHackathonId.ToString("D") + : normalizedShortCode; + var hackathonCacheKey = $"hackathon:public-details:{cacheKeySegment}"; var hackathon = await sql.Queryable() .Where(h => - h.Id.ToString() == req.HackathonIdOrShortCode - || h.ShortCode == req.HackathonIdOrShortCode + (isGuidToken && h.Id == parsedHackathonId) + || ( + !isGuidToken + && h.ShortCode != null + && SqlFunc.ToLower(h.ShortCode) == normalizedShortCode + ) ) .WithCache(hackathonCacheKey) .FirstAsync(ct);