From 2ae0eeb06b1464ca7013b85b209f9278a421b7a9 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 12:08:13 +0200 Subject: [PATCH 1/2] fix(analytics): recalculate delete side effects asynchronously Move achievement and summary rebuilds out of the activity delete request path so the UI is not blocked by long-running recalculations. Signed-off-by: Marcus Fihlon --- .../ActivityDeleteRecalculationService.java | 30 ++++++++++++++ .../fitpub/service/ActivityDeletedEvent.java | 7 ++++ .../fitpub/service/FitFileService.java | 10 ++--- ...ctivityDeleteRecalculationServiceTest.java | 40 +++++++++++++++++++ .../fitpub/service/FitFileServiceTest.java | 17 ++++---- 5 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java create mode 100644 src/main/java/net/javahippie/fitpub/service/ActivityDeletedEvent.java create mode 100644 src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java b/src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java new file mode 100644 index 0000000..61d64f1 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationService.java @@ -0,0 +1,30 @@ +package net.javahippie.fitpub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Recalculates derived analytics after an activity has been deleted. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ActivityDeleteRecalculationService { + + private final AchievementService achievementService; + private final ActivitySummaryService activitySummaryService; + + @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()); + } +} diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityDeletedEvent.java b/src/main/java/net/javahippie/fitpub/service/ActivityDeletedEvent.java new file mode 100644 index 0000000..9eb069c --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/ActivityDeletedEvent.java @@ -0,0 +1,7 @@ +package net.javahippie.fitpub.service; + +import java.time.LocalDate; +import java.util.UUID; + +public record ActivityDeletedEvent(UUID userId, LocalDate activityDate) { +} diff --git a/src/main/java/net/javahippie/fitpub/service/FitFileService.java b/src/main/java/net/javahippie/fitpub/service/FitFileService.java index 39c8b76..6e3b146 100644 --- a/src/main/java/net/javahippie/fitpub/service/FitFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/FitFileService.java @@ -18,6 +18,7 @@ import net.javahippie.fitpub.util.FitFileValidator; import net.javahippie.fitpub.util.FitParser; import net.javahippie.fitpub.util.ParsedActivityData; import net.javahippie.fitpub.util.TrackSimplifier; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -52,6 +53,7 @@ public class FitFileService { private final ActivitySummaryService activitySummaryService; private final WeatherService weatherService; private final HeatmapGridService heatmapGridService; + private final ApplicationEventPublisher applicationEventPublisher; /** * Processes an uploaded FIT file and creates an activity. @@ -319,12 +321,10 @@ public class FitFileService { return activityRepository.findByIdAndUserId(activityId, userId) .map(activity -> { activityRepository.delete(activity); - achievementService.rebuildAchievementsForUser(userId); if (activity.getStartedAt() != null) { - java.time.LocalDate activityDate = activity.getStartedAt().toLocalDate(); - activitySummaryService.updateWeeklySummary(userId, activityDate); - activitySummaryService.updateMonthlySummary(userId, activityDate); - activitySummaryService.updateYearlySummary(userId, activityDate); + applicationEventPublisher.publishEvent( + new ActivityDeletedEvent(userId, activity.getStartedAt().toLocalDate()) + ); } log.info("Deleted activity {} for user {}", activityId, userId); return true; diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java new file mode 100644 index 0000000..eecb5d8 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivityDeleteRecalculationServiceTest.java @@ -0,0 +1,40 @@ +package net.javahippie.fitpub.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ActivityDeleteRecalculationServiceTest { + + @Mock + private AchievementService achievementService; + + @Mock + private ActivitySummaryService activitySummaryService; + + @InjectMocks + private ActivityDeleteRecalculationService activityDeleteRecalculationService; + + @Test + @DisplayName("Should rebuild achievements and summaries after activity deletion") + void shouldRebuildAchievementsAndSummariesAfterActivityDeletion() { + UUID userId = UUID.randomUUID(); + LocalDate activityDate = LocalDate.of(2025, 12, 3); + + activityDeleteRecalculationService.handleActivityDeleted(new ActivityDeletedEvent(userId, activityDate)); + + verify(achievementService).rebuildAchievementsForUser(userId); + verify(activitySummaryService).updateWeeklySummary(userId, activityDate); + verify(activitySummaryService).updateMonthlySummary(userId, activityDate); + verify(activitySummaryService).updateYearlySummary(userId, activityDate); + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java index 0ab3ac1..1665c11 100644 --- a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java @@ -21,6 +21,7 @@ import net.javahippie.fitpub.util.FitFileValidator; import net.javahippie.fitpub.util.FitParser; import net.javahippie.fitpub.util.ParsedActivityData; import net.javahippie.fitpub.util.TrackSimplifier; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.mock.web.MockMultipartFile; import java.math.BigDecimal; @@ -74,6 +75,9 @@ class FitFileServiceTest { @Mock private HeatmapGridService heatmapGridService; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @Spy private ObjectMapper objectMapper; @@ -240,10 +244,10 @@ class FitFileServiceTest { // Assert assertTrue(result); verify(activityRepository).delete(activity); - verify(achievementService).rebuildAchievementsForUser(testUserId); - verify(activitySummaryService).updateWeeklySummary(testUserId, startedAt.toLocalDate()); - verify(activitySummaryService).updateMonthlySummary(testUserId, startedAt.toLocalDate()); - verify(activitySummaryService).updateYearlySummary(testUserId, startedAt.toLocalDate()); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ActivityDeletedEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertEquals(testUserId, eventCaptor.getValue().userId()); + assertEquals(startedAt.toLocalDate(), eventCaptor.getValue().activityDate()); } @Test @@ -260,10 +264,7 @@ class FitFileServiceTest { // Assert assertFalse(result); verify(activityRepository, never()).delete(any()); - verify(achievementService, never()).rebuildAchievementsForUser(any()); - verify(activitySummaryService, never()).updateWeeklySummary(any(), any()); - verify(activitySummaryService, never()).updateMonthlySummary(any(), any()); - verify(activitySummaryService, never()).updateYearlySummary(any(), any()); + verify(applicationEventPublisher, never()).publishEvent(any()); } @Test From 714007aabedfc9cfed135cca450fd80ddffc98c7 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 12:11:54 +0200 Subject: [PATCH 2/2] fix(analytics): serialize delete recalculations per user Prevent concurrent achievement and summary rebuilds for the same user when multiple activities are deleted in quick succession. Signed-off-by: Marcus Fihlon --- .../ActivityDeleteRecalculationService.java | 62 +++++++++++++++++-- ...ctivityDeleteRecalculationServiceTest.java | 29 +++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) 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); + } }