diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java b/src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java index 61d64f1..eff267a 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java @@ -7,6 +7,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + /** * Recalculates derived analytics after an activity has been deleted. */ @@ -17,14 +24,59 @@ public class ActivityDeleteRecalculationService { private final AchievementService achievementService; private final ActivitySummaryService activitySummaryService; + private final ConcurrentMap pendingRecalculations = new ConcurrentHashMap<>(); @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleActivityDeleted(ActivityDeletedEvent event) { - achievementService.rebuildAchievementsForUser(event.userId()); - activitySummaryService.updateWeeklySummary(event.userId(), event.activityDate()); - activitySummaryService.updateMonthlySummary(event.userId(), event.activityDate()); - activitySummaryService.updateYearlySummary(event.userId(), event.activityDate()); - log.info("Recalculated achievements and summaries after deleting activity for user {}", event.userId()); + PendingUserRecalculation pending = pendingRecalculations.computeIfAbsent( + event.userId(), + ignored -> new PendingUserRecalculation() + ); + + if (!pending.enqueueAndShouldStart(event.activityDate())) { + log.debug("Queued additional activity delete recalculation for user {}", event.userId()); + return; + } + + do { + Set datesToRecalculate = pending.drainDates(); + achievementService.rebuildAchievementsForUser(event.userId()); + for (LocalDate date : datesToRecalculate) { + activitySummaryService.updateWeeklySummary(event.userId(), date); + activitySummaryService.updateMonthlySummary(event.userId(), date); + activitySummaryService.updateYearlySummary(event.userId(), date); + } + } while (pending.keepProcessing()); + + log.info("Recalculated achievements and summaries after deleting activities for user {}", event.userId()); + } + + private static final class PendingUserRecalculation { + private final Set pendingDates = new HashSet<>(); + private boolean processing; + + synchronized boolean enqueueAndShouldStart(LocalDate activityDate) { + pendingDates.add(activityDate); + if (processing) { + return false; + } + processing = true; + return true; + } + + synchronized Set drainDates() { + Set dates = new HashSet<>(pendingDates); + pendingDates.clear(); + return dates; + } + + synchronized boolean keepProcessing() { + if (pendingDates.isEmpty()) { + processing = false; + return false; + } + return true; + } } } diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java index eecb5d8..dd2f522 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java @@ -9,7 +9,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDate; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -37,4 +40,30 @@ class ActivityDeleteRecalculationServiceTest { verify(activitySummaryService).updateMonthlySummary(userId, activityDate); verify(activitySummaryService).updateYearlySummary(userId, activityDate); } + + @Test + @DisplayName("Should serialize recalculations per user and replay queued deletions") + void shouldSerializeRecalculationsPerUserAndReplayQueuedDeletions() { + UUID userId = UUID.randomUUID(); + LocalDate firstDate = LocalDate.of(2025, 12, 3); + LocalDate secondDate = LocalDate.of(2025, 12, 4); + AtomicBoolean queuedSecondDelete = new AtomicBoolean(false); + + doAnswer(invocation -> { + if (queuedSecondDelete.compareAndSet(false, true)) { + activityDeleteRecalculationService.handleActivityDeleted(new ActivityDeletedEvent(userId, secondDate)); + } + return null; + }).when(achievementService).rebuildAchievementsForUser(userId); + + activityDeleteRecalculationService.handleActivityDeleted(new ActivityDeletedEvent(userId, firstDate)); + + verify(achievementService, times(2)).rebuildAchievementsForUser(userId); + verify(activitySummaryService).updateWeeklySummary(userId, firstDate); + verify(activitySummaryService).updateMonthlySummary(userId, firstDate); + verify(activitySummaryService).updateYearlySummary(userId, firstDate); + verify(activitySummaryService).updateWeeklySummary(userId, secondDate); + verify(activitySummaryService).updateMonthlySummary(userId, secondDate); + verify(activitySummaryService).updateYearlySummary(userId, secondDate); + } }