fix(summaries): retry summary updates after concurrent insert conflicts

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-28 16:13:32 +02:00
parent 9e529f8b99
commit 5f035d75b6
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
2 changed files with 135 additions and 29 deletions

View file

@ -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<Activity> activities = activityRepository
.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(userId, startDateTime, endDateTime);

View file

@ -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));
}
}