fix(summaries): retry summary updates after concurrent insert conflicts
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
9e529f8b99
commit
5f035d75b6
2 changed files with 135 additions and 29 deletions
|
|
@ -8,6 +8,7 @@ import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
|
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
|
||||||
import net.javahippie.fitpub.repository.AchievementRepository;
|
import net.javahippie.fitpub.repository.AchievementRepository;
|
||||||
import net.javahippie.fitpub.repository.PersonalRecordRepository;
|
import net.javahippie.fitpub.repository.PersonalRecordRepository;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
@ -67,17 +68,7 @@ public class ActivitySummaryService {
|
||||||
LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||||
LocalDate weekEnd = weekStart.plusDays(6);
|
LocalDate weekEnd = weekStart.plusDays(6);
|
||||||
|
|
||||||
ActivitySummary summary = activitySummaryRepository
|
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -88,17 +79,7 @@ public class ActivitySummaryService {
|
||||||
LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth());
|
LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth());
|
||||||
LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth());
|
LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
|
||||||
ActivitySummary summary = activitySummaryRepository
|
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -109,24 +90,53 @@ public class ActivitySummaryService {
|
||||||
LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear());
|
LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear());
|
||||||
LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear());
|
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
|
ActivitySummary summary = activitySummaryRepository
|
||||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart)
|
.findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart)
|
||||||
.orElse(ActivitySummary.builder()
|
.orElse(ActivitySummary.builder()
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.periodType(ActivitySummary.PeriodType.YEAR)
|
.periodType(periodType)
|
||||||
.periodStart(yearStart)
|
.periodStart(periodStart)
|
||||||
.periodEnd(yearEnd)
|
.periodEnd(periodEnd)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay());
|
LocalDateTime startDateTime = periodStart.atStartOfDay();
|
||||||
activitySummaryRepository.save(summary);
|
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.
|
* Calculate and update summary statistics.
|
||||||
*/
|
*/
|
||||||
private void calculateAndUpdateSummary(ActivitySummary summary, UUID userId,
|
private void calculateAndUpdateSummary(ActivitySummary summary, UUID userId,
|
||||||
LocalDateTime startDateTime, LocalDateTime endDateTime) {
|
LocalDateTime startDateTime, LocalDateTime endDateTime) {
|
||||||
// Get activities in period
|
// Get activities in period
|
||||||
List<Activity> activities = activityRepository
|
List<Activity> activities = activityRepository
|
||||||
.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(userId, startDateTime, endDateTime);
|
.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(userId, startDateTime, endDateTime);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue