From 5f035d75b6c3fd07c6a4ad9e151aaf4f90805579 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 16:13:32 +0200 Subject: [PATCH] fix(summaries): retry summary updates after concurrent insert conflicts Signed-off-by: Marcus Fihlon --- .../service/ActivitySummaryService.java | 68 +++++++------ .../service/ActivitySummaryServiceTest.java | 96 +++++++++++++++++++ 2 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java diff --git a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java index 357bc8d..d7ff249 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java @@ -8,6 +8,7 @@ import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.ActivitySummaryRepository; import net.javahippie.fitpub.repository.AchievementRepository; import net.javahippie.fitpub.repository.PersonalRecordRepository; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -67,17 +68,7 @@ public class ActivitySummaryService { LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); LocalDate weekEnd = weekStart.plusDays(6); - ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart) - .orElse(ActivitySummary.builder() - .userId(userId) - .periodType(ActivitySummary.PeriodType.WEEK) - .periodStart(weekStart) - .periodEnd(weekEnd) - .build()); - - calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay()); - activitySummaryRepository.save(summary); + saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd); } /** @@ -88,17 +79,7 @@ public class ActivitySummaryService { LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth()); LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth()); - ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart) - .orElse(ActivitySummary.builder() - .userId(userId) - .periodType(ActivitySummary.PeriodType.MONTH) - .periodStart(monthStart) - .periodEnd(monthEnd) - .build()); - - calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay()); - activitySummaryRepository.save(summary); + saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd); } /** @@ -109,24 +90,53 @@ public class ActivitySummaryService { LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear()); LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear()); + saveSummaryWithRetry(userId, ActivitySummary.PeriodType.YEAR, yearStart, yearEnd); + } + + private void saveSummaryWithRetry( + UUID userId, + ActivitySummary.PeriodType periodType, + LocalDate periodStart, + LocalDate periodEnd + ) { ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) .orElse(ActivitySummary.builder() .userId(userId) - .periodType(ActivitySummary.PeriodType.YEAR) - .periodStart(yearStart) - .periodEnd(yearEnd) + .periodType(periodType) + .periodStart(periodStart) + .periodEnd(periodEnd) .build()); - calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay()); - activitySummaryRepository.save(summary); + LocalDateTime startDateTime = periodStart.atStartOfDay(); + LocalDateTime endDateTime = periodEnd.plusDays(1).atStartOfDay(); + + calculateAndUpdateSummary(summary, userId, startDateTime, endDateTime); + + try { + activitySummaryRepository.save(summary); + } catch (DataIntegrityViolationException e) { + log.debug( + "Summary already created concurrently for user {}, period {} starting {}. Retrying as update.", + userId, + periodType, + periodStart + ); + + ActivitySummary existingSummary = activitySummaryRepository + .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) + .orElseThrow(() -> e); + + calculateAndUpdateSummary(existingSummary, userId, startDateTime, endDateTime); + activitySummaryRepository.save(existingSummary); + } } /** * Calculate and update summary statistics. */ private void calculateAndUpdateSummary(ActivitySummary summary, UUID userId, - LocalDateTime startDateTime, LocalDateTime endDateTime) { + LocalDateTime startDateTime, LocalDateTime endDateTime) { // Get activities in period List activities = activityRepository .findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(userId, startDateTime, endDateTime); diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java new file mode 100644 index 0000000..37d5681 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java @@ -0,0 +1,96 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivitySummary; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.ActivitySummaryRepository; +import net.javahippie.fitpub.repository.AchievementRepository; +import net.javahippie.fitpub.repository.PersonalRecordRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ActivitySummaryServiceTest { + + private ActivitySummaryRepository activitySummaryRepository; + private ActivityRepository activityRepository; + private PersonalRecordRepository personalRecordRepository; + private AchievementRepository achievementRepository; + private ActivitySummaryService service; + + @BeforeEach + void setUp() { + activitySummaryRepository = mock(ActivitySummaryRepository.class); + activityRepository = mock(ActivityRepository.class); + personalRecordRepository = mock(PersonalRecordRepository.class); + achievementRepository = mock(AchievementRepository.class); + service = new ActivitySummaryService( + activitySummaryRepository, + activityRepository, + personalRecordRepository, + achievementRepository + ); + } + + @Test + @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") + void shouldRetrySummarySaveAfterConcurrentInsert() { + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + LocalDate date = LocalDate.of(2025, 10, 8); + LocalDate weekStart = LocalDate.of(2025, 10, 6); + LocalDate weekEnd = LocalDate.of(2025, 10, 12); + + ActivitySummary existingSummary = ActivitySummary.builder() + .id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + .userId(userId) + .periodType(ActivitySummary.PeriodType.WEEK) + .periodStart(weekStart) + .periodEnd(weekEnd) + .build(); + + //noinspection unchecked + when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( + userId, ActivitySummary.PeriodType.WEEK, weekStart + )).thenReturn(Optional.empty(), Optional.of(existingSummary)); + + when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( + eq(userId), + eq(weekStart.atStartOfDay()), + eq(weekEnd.plusDays(1).atStartOfDay()) + )).thenReturn(List.of( + Activity.builder() + .userId(userId) + .activityType(Activity.ActivityType.RIDE) + .startedAt(LocalDateTime.of(2025, 10, 8, 9, 0)) + .totalDurationSeconds(3600L) + .build() + )); + + when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + + when(activitySummaryRepository.save(any(ActivitySummary.class))) + .thenThrow(new DataIntegrityViolationException("duplicate")) + .thenReturn(existingSummary); + + service.updateWeeklySummary(userId, date); + + verify(activitySummaryRepository, times(2)) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); + verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); + } +} -- 2.49.1