From 6d49e3e5fe8d03255ab2171dfc42774b74090446 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 00:14:27 -0300 Subject: [PATCH 1/4] chore: open ORB-9 implementation From 652a7e3d063535f9f51f1024f15a19932e4bda9f Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 00:17:40 -0300 Subject: [PATCH 2/4] fix: align retrospective cache invalidation --- .../Common/CacheInvalidationHelper.cs | 14 ++--- .../Common/RetrospectiveCacheKey.cs | 7 +++ .../Habits/Queries/GetRetrospectiveQuery.cs | 6 ++- .../Habits/LogHabitCommandHandlerTests.cs | 53 +++++++++++++++++++ .../Common/CacheInvalidationHelperTests.cs | 36 +++++++++++-- 5 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 src/Orbit.Application/Common/RetrospectiveCacheKey.cs diff --git a/src/Orbit.Application/Common/CacheInvalidationHelper.cs b/src/Orbit.Application/Common/CacheInvalidationHelper.cs index 4fd50428..30a6726e 100644 --- a/src/Orbit.Application/Common/CacheInvalidationHelper.cs +++ b/src/Orbit.Application/Common/CacheInvalidationHelper.cs @@ -1,10 +1,12 @@ using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Habits.Queries; namespace Orbit.Application.Common; public static class CacheInvalidationHelper { private static readonly string[] RetrospectivePeriods = ["week", "month", "quarter", "semester", "year"]; + private static readonly int[] RetrospectiveWeekStartDays = [0, 1]; private static readonly string[] SummaryTimeBuckets = ["morning", "afternoon", "evening", "night", "timeless"]; public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId, DateOnly today) @@ -28,13 +30,13 @@ public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId, DateO /// public static void InvalidateRetrospectiveCache(IMemoryCache cache, Guid userId, DateOnly today) { - for (int i = -2; i <= 2; i++) - { - var date = today.AddDays(i); - foreach (var period in RetrospectivePeriods) + foreach (var period in RetrospectivePeriods) + foreach (var weekStartDay in RetrospectiveWeekStartDays) + { + var (dateFrom, _) = RetrospectivePeriodRange.Resolve(period, today, weekStartDay); foreach (var lang in AppConstants.SupportedLanguages) - cache.Remove($"retro:{userId}:{period}:{date}:{lang}"); - } + cache.Remove(RetrospectiveCacheKey.Build(userId, period, dateFrom, lang)); + } } /// diff --git a/src/Orbit.Application/Common/RetrospectiveCacheKey.cs b/src/Orbit.Application/Common/RetrospectiveCacheKey.cs new file mode 100644 index 00000000..8bf8df1b --- /dev/null +++ b/src/Orbit.Application/Common/RetrospectiveCacheKey.cs @@ -0,0 +1,7 @@ +namespace Orbit.Application.Common; + +public static class RetrospectiveCacheKey +{ + public static string Build(Guid userId, string period, DateOnly dateFrom, string language) => + $"retro:v2:{userId}:{period}:{dateFrom}:{language}"; +} diff --git a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs index 34b045b6..b5c661d4 100644 --- a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs @@ -59,7 +59,11 @@ public async Task> Handle( if (gateCheck.IsFailure) return gateCheck.PropagateError(); - var cacheKey = $"retro:v2:{request.UserId}:{request.Period}:{request.DateFrom}:{request.Language}"; + var cacheKey = RetrospectiveCacheKey.Build( + request.UserId, + request.Period, + request.DateFrom, + request.Language); if (cache.TryGetValue(cacheKey, out RetrospectiveResponse? cached) && cached is not null) return Result.Success(cached with { FromCache = true }); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs index dba9caca..6ceb7153 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs @@ -5,7 +5,10 @@ using NSubstitute; using NSubstitute.ExceptionExtensions; using Orbit.Application.Challenges.Services; +using Orbit.Application.Common; using Orbit.Application.Habits.Commands; +using Orbit.Application.Habits.Queries; +using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; @@ -210,6 +213,56 @@ public async Task Handle_InvalidatesSummaryCache() _cache.TryGetValue(cacheKey, out _).Should().BeFalse(); } + [Fact] + public async Task Handle_LogInvalidatesCachedRetrospective_SoNextReadIsFresh() + { + var habit = CreateTestHabit(); + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(_ => new[] { habit }); + + var (dateFrom, dateTo) = RetrospectivePeriodRange.Resolve("week", Today, weekStartDay: 1); + var cacheKey = RetrospectiveCacheKey.Build(UserId, "week", dateFrom, "en"); + var staleNarrative = new RetrospectiveNarrative("Stale", "", "", ""); + var emptyMetrics = new RetrospectiveMetrics(0, 0, 0, 0, 0, 0, 0, 0, new int[7], [], []); + _cache.Set(cacheKey, new RetrospectiveResponse("week", emptyMetrics, staleNarrative, FromCache: false)); + + var payGate = Substitute.For(); + var retrospectiveService = Substitute.For(); + var freshNarrative = new RetrospectiveNarrative("Fresh", "", "", ""); + payGate.CanUseRetrospective(UserId, Arg.Any()).Returns(Result.Success()); + retrospectiveService.GenerateRetrospectiveAsync( + Arg.Any>(), + dateFrom, + dateTo, + "week", + "en", + Arg.Any()) + .Returns(Result.Success(freshNarrative)); + var queryHandler = new GetRetrospectiveQueryHandler( + _habitRepo, + payGate, + retrospectiveService, + _userStreakService, + _cache); + + await _handler.Handle(new LogHabitCommand(UserId, habit.Id), CancellationToken.None); + var result = await queryHandler.Handle( + new GetRetrospectiveQuery(UserId, dateFrom, dateTo, "week", "en"), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.FromCache.Should().BeFalse(); + result.Value.Narrative.Should().Be(freshNarrative); + } + [Fact] public async Task Handle_FutureDateOnRecurring_ReturnsFailure() { diff --git a/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs b/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs index 9bf0436d..1d162b61 100644 --- a/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs +++ b/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs @@ -1,11 +1,26 @@ using FluentAssertions; using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; +using Orbit.Application.Habits.Queries; namespace Orbit.Application.Tests.Common; public class CacheInvalidationHelperTests { + public static TheoryData RetrospectiveKeyCases + { + get + { + var cases = new TheoryData(); + foreach (var period in new[] { "week", "month", "quarter", "semester", "year" }) + foreach (var language in AppConstants.SupportedLanguages) + foreach (var weekStartDay in new[] { 0, 1 }) + cases.Add(period, language, weekStartDay); + + return cases; + } + } + [Fact] public void InvalidateSummaryCache_RemovesSummaryKeys() { @@ -62,17 +77,28 @@ public void InvalidateSummaryCache_UsesSuppliedTodayNotUtc() cache.TryGetValue(utcKey, out _).Should().BeTrue("an unrelated UTC-dated key stays untouched"); } - [Fact] - public void InvalidateRetrospectiveCache_RemovesKeysAroundSuppliedToday() + [Theory] + [MemberData(nameof(RetrospectiveKeyCases))] + public void InvalidateRetrospectiveCache_RemovesSharedKey_AndPreservesUnrelatedKeys( + string period, + string language, + int weekStartDay) { var cache = new MemoryCache(new MemoryCacheOptions()); var userId = Guid.NewGuid(); var today = new DateOnly(2020, 1, 15); - var key = $"retro:{userId}:week:{today}:en"; - cache.Set(key, "cached"); + var (dateFrom, _) = RetrospectivePeriodRange.Resolve(period, today, weekStartDay); + var targetKey = RetrospectiveCacheKey.Build(userId, period, dateFrom, language); + var otherUserKey = RetrospectiveCacheKey.Build(Guid.NewGuid(), period, dateFrom, language); + var otherWindowKey = RetrospectiveCacheKey.Build(userId, period, dateFrom.AddYears(-1), language); + cache.Set(targetKey, "target"); + cache.Set(otherUserKey, "other-user"); + cache.Set(otherWindowKey, "other-window"); CacheInvalidationHelper.InvalidateRetrospectiveCache(cache, userId, today); - cache.TryGetValue(key, out _).Should().BeFalse(); + cache.TryGetValue(targetKey, out _).Should().BeFalse(); + cache.TryGetValue(otherUserKey, out _).Should().BeTrue(); + cache.TryGetValue(otherWindowKey, out _).Should().BeTrue(); } } From 6edb35bb70ae85367f5e60b5f5f906c261e0178a Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 00:20:09 -0300 Subject: [PATCH 3/4] chore: refresh architecture map --- architecture.html | 2 +- architecture.json | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/architecture.html b/architecture.html index 2033cc4b..3a4870be 100644 --- a/architecture.html +++ b/architecture.html @@ -47,7 +47,7 @@

Handlers with no endpoint

RequestHandler file

Entities

EntityDomain file
- +