diff --git a/backend/src/test/java/com/shadowfit/repository/outbox/OutboxSkipLockedRaceTest.java b/backend/src/test/java/com/shadowfit/repository/outbox/OutboxSkipLockedRaceTest.java new file mode 100644 index 00000000..77dff1bb --- /dev/null +++ b/backend/src/test/java/com/shadowfit/repository/outbox/OutboxSkipLockedRaceTest.java @@ -0,0 +1,149 @@ +package com.shadowfit.repository.outbox; + +import com.shadowfit.model.outbox.OutboxEvent; +import com.shadowfit.support.MySqlContainerSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * outbox 선점 쿼리({@code FOR UPDATE SKIP LOCKED})가 실제 MySQL 에서 «발행기끼리 행을 나눠 갖는다» 를 + * 지키는지 검증한다. + * + *

왜 H2 로는 안 되는가. 기본 테스트(H2)는 이 쿼리의 문법만 통과시킨다. 두 트랜잭션이 + * 동시에 잡을 때 잠긴 행을 건너뛰는지·기다리는지는 스토리지 엔진의 행 락 동작이라 InnoDB 에서만 + * 의미가 있다. 이 동작은 PR #63 때 손으로만 확인했고 테스트가 없었다. + * + *

무엇을 고정하나. + *

    + *
  1. 발행기 A 가 행을 잠근 채 트랜잭션을 열어 두는 동안 발행기 B 가 같은 쿼리를 돌리면, + * B 는 기다리지 않고(락 대기 시간 안에 끝남) A 가 잡지 않은 행만 받는다.
  2. + *
  3. 유실 회수 쿼리는 lease 가 만료된 PROCESSING 만 집는다 — 아직 살아 있는 lease 는 + * 남의 송신 중이므로 건드리면 중복 송신이다.
  4. + *
+ * + *

실제 발행기와 격리 — 시각을 미래로 민다. 이 클래스의 컨텍스트는 스케줄러를 끄지만 + * ({@code scheduling.enabled=false}, {@code SessionCompletedFeedFanoutRaceTest} 와 같은 설정이라 컨텍스트를 + * 같이 쓴다), 캐시된 다른 컨텍스트(스케줄러가 켜진 race 테스트)의 {@code OutboxPublisher} 는 같은 + * 컨테이너를 1초마다 폴링한다. 전체 스위트에서 실제로 그 발행기가 이 테스트의 행을 먼저 집어 가 + * B 가 1건만 받은 적이 있다(2026-09-24). 그래서 행의 {@code next_retry_at}·{@code lock_expires_at} 을 + * {@link #FUTURE} 뒤로 두고, 선점 쿼리의 {@code :now} 에 그 미래 시각을 넘긴다 — 실제 발행기는 + * {@code LocalDateTime.now()} 로 물으므로 이 행들을 절대 못 집는다. + */ +@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"}) +@ActiveProfiles("race") +@DisplayName("outbox SKIP LOCKED — 발행기끼리 행을 나눠 갖는다 (실 MySQL)") +class OutboxSkipLockedRaceTest extends MySqlContainerSupport { + + private static final List TYPES = List.of("STOP_ANALYSIS"); + private static final int ROWS = 10; + private static final int BATCH = 4; + /** A 가 락을 쥐고 버티는 최대 시간. B 가 이보다 빨리 끝났다면 A 의 락을 기다리지 않은 것이다. */ + private static final long HOLD_SECONDS = 30; + /** 실제 발행기의 «지금» 보다 확실히 뒤 — 테스트가 이 시각을 :now 로 넘긴다. */ + private static final LocalDateTime FUTURE = LocalDateTime.now().plusDays(1); + + @Autowired private OutboxEventRepository outboxEventRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private PlatformTransactionManager transactionManager; + + @BeforeEach + void setUp() { + // 큐 표라 다른 테스트가 남긴 PENDING 이 섞이면 «누가 무엇을 집었나» 를 셀 수 없다. 이 클래스가 + // 도는 동안 스케줄러는 꺼져 있으므로 비워도 경합하는 쪽이 없다. + jdbcTemplate.update("DELETE FROM outbox_events"); + for (int i = 0; i < ROWS; i++) { + jdbcTemplate.update("INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, status, next_retry_at) " + + "VALUES ('SESSION', ?, 'STOP_ANALYSIS', JSON_OBJECT('sessionId', ?), 'PENDING', ?)", i, i, FUTURE); + } + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM outbox_events"); + } + + @Test + @DisplayName("A 가 잠근 동안 B 는 기다리지 않고 A 가 안 잡은 행만 받는다") + void concurrentClaims_areDisjointAndDoNotBlock() throws Exception { + CountDownLatch aLocked = new CountDownLatch(1); + CountDownLatch bDone = new CountDownLatch(1); + TransactionTemplate tx = new TransactionTemplate(transactionManager); + + CompletableFuture> a = CompletableFuture.supplyAsync(() -> tx.execute(status -> { + Set ids = idsOf(outboxEventRepository.lockPendingBatch(TYPES, FUTURE.plusMinutes(1), BATCH)); + aLocked.countDown(); + await(bDone); // B 가 끝날 때까지 락을 쥔 채 트랜잭션을 열어 둔다 + return ids; + })); + + assertThat(aLocked.await(HOLD_SECONDS, TimeUnit.SECONDS)).as("A 가 선점을 마쳐야 한다").isTrue(); + + long started = System.nanoTime(); + Set b = tx.execute(status -> + idsOf(outboxEventRepository.lockPendingBatch(TYPES, FUTURE.plusMinutes(1), BATCH))); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + bDone.countDown(); + Set aIds = a.get(HOLD_SECONDS * 2, TimeUnit.SECONDS); + + assertThat(aIds).hasSize(BATCH); + assertThat(b).as("B 도 한 배치를 채운다 — 잠긴 행을 건너뛰고 다음 행을 집는다").hasSize(BATCH); + assertThat(b).as("SKIP LOCKED — 같은 행을 두 발행기가 집으면 중복 송신이다").doesNotContainAnyElementsOf(aIds); + // 문턱은 성능 목표가 아니라 구조에서 나온다: A 는 B 가 끝나야(bDone) 락을 놓고, 못 받으면 + // HOLD_SECONDS 뒤에야 놓는다. B 가 락을 기다렸다면 HOLD_SECONDS 이전엔 돌아올 수 없다. + assertThat(elapsedMs).as("B 가 A 의 락에 막히지 않았다 (%d ms)", elapsedMs) + .isLessThan(TimeUnit.SECONDS.toMillis(HOLD_SECONDS)); + } + + @Test + @DisplayName("회수 쿼리는 lease 가 만료된 PROCESSING 만 집는다") + void staleReclaim_picksOnlyExpiredLeases() { + LocalDateTime now = FUTURE.plusMinutes(1); + List ids = jdbcTemplate.queryForList("SELECT id FROM outbox_events ORDER BY id", Long.class); + long expired = ids.get(0); + long alive = ids.get(1); + jdbcTemplate.update("UPDATE outbox_events SET status = 'PROCESSING', locked_by = 'dead', lock_expires_at = ? WHERE id = ?", + now.minusMinutes(1), expired); + jdbcTemplate.update("UPDATE outbox_events SET status = 'PROCESSING', locked_by = 'busy', lock_expires_at = ? WHERE id = ?", + now.plusMinutes(1), alive); + + Set reclaimed = new TransactionTemplate(transactionManager).execute(status -> + idsOf(outboxEventRepository.lockStaleProcessingBatch(TYPES, now, ROWS))); + + assertThat(reclaimed).containsExactly(expired); + } + + private static Set idsOf(List events) { + Set ids = new HashSet<>(); + events.forEach(e -> ids.add(e.getId())); + return ids; + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(HOLD_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("B 가 " + HOLD_SECONDS + "초 안에 끝나지 않았다 — A 의 락에 막혔을 수 있다"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } +} diff --git a/backend/src/test/java/com/shadowfit/repository/report/DailyLogNativeQueryRaceTest.java b/backend/src/test/java/com/shadowfit/repository/report/DailyLogNativeQueryRaceTest.java new file mode 100644 index 00000000..e3219541 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/repository/report/DailyLogNativeQueryRaceTest.java @@ -0,0 +1,163 @@ +package com.shadowfit.repository.report; + +import com.shadowfit.dto.report.record.DailyLogRequestDto; +import com.shadowfit.dto.report.record.Mood; +import com.shadowfit.model.exercise.Category; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.CategoryRepository; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import com.shadowfit.service.report.DailyLogService; +import com.shadowfit.support.MySqlContainerSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code DailyLogRepository} 의 네이티브 쿼리 두 개를 실제 MySQL 에서 돌린다. + * ({@code upsertStats} 는 {@code WeeklySummaryBLayerRaceTest} 가 이미 MySQL 에서 본다.) + * + *

왜 H2 로는 모자란가. + *

+ * + *

{@code @Transactional} 로 롤백한다 — 동시성을 보는 테스트가 아니라 한 연결 안에서 끝난다. + */ +@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"}) +@ActiveProfiles("race") +@Transactional +@DisplayName("daily_logs 네이티브 쿼리 — upsertMemoAndMood·recomputeStats (실 MySQL)") +class DailyLogNativeQueryRaceTest extends MySqlContainerSupport { + + private static final LocalDate DAY = LocalDate.of(2026, 9, 10); + + @Autowired private DailyLogService dailyLogService; + @Autowired private MemberRepository memberRepository; + @Autowired private CategoryRepository categoryRepository; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private Member member; + private Member other; + private Exercise exercise; + + @BeforeEach + void setUp() { + member = memberRepository.saveAndFlush(Member.builder() + .email("dailylog-race@test.local").username("dailylog-race").password("x") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + other = memberRepository.saveAndFlush(Member.builder() + .email("dailylog-race-other@test.local").username("dailylog-race-other").password("x") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + Category category = categoryRepository.saveAndFlush(Category.builder().name("DAILYLOG_RACE").build()); + exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("dailylog-race-squat").category(category).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + } + + @ParameterizedTest + @EnumSource(Mood.class) + @DisplayName("upsertMemoAndMood — 자바 Mood 의 모든 값이 MySQL ENUM 컬럼에 들어간다") + void everyMoodFitsTheEnumColumn(Mood mood) { + dailyLogService.saveOrUpdateLog(member.getId(), new DailyLogRequestDto(DAY, "memo", mood)); + + assertThat(row().get("mood")).isEqualTo(mood.name()); + } + + @Test + @DisplayName("upsertMemoAndMood — 두 번째 쓰기가 memo·mood 를 덮고, 누적 통계는 건드리지 않는다") + void secondWriteOverwritesMemoButKeepsStats() { + dailyLogService.accumulateStats(member.getId(), DAY, 30, new BigDecimal("120.00")); + dailyLogService.saveOrUpdateLog(member.getId(), new DailyLogRequestDto(DAY, "first", Mood.GOOD)); + + dailyLogService.saveOrUpdateLog(member.getId(), new DailyLogRequestDto(DAY, "second", null)); + + Map row = row(); + assertThat(row.get("memo")).isEqualTo("second"); + assertThat(row.get("mood")).as("null 도 «마지막 입력이 이긴다» 의 일부다").isNull(); + assertThat(((Number) row.get("total_exercise_time")).intValue()).isEqualTo(30); + assertThat((BigDecimal) row.get("total_calories")).isEqualByComparingTo("120.00"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM daily_logs WHERE member_id = ? AND log_date = ?", Integer.class, member.getId(), DAY)) + .as("uk_member_date — 행은 하나").isEqualTo(1); + } + + @Test + @DisplayName("recomputeStats — 그날 COMPLETED 세션만, 세션별 분 절삭 합으로 다시 센다") + void recomputeCountsOnlyCompletedSessionsOfThatDay() { + // 세션별 TIMESTAMPDIFF(MINUTE) 는 절삭: 10분59초 → 10, 5분30초 → 5. 합 15. + completed(member, DAY.atTime(9, 0, 0), DAY.atTime(9, 10, 59), "30.50"); + completed(member, DAY.atTime(23, 50, 0), DAY.atTime(23, 55, 30), "20.25"); + // 세지 않을 것들: 다른 상태 · 다음 날 0시 시작(경계 밖) · 남의 세션 + session(member, Status.FAILED, DAY.atTime(12, 0), DAY.atTime(12, 30), "99.00"); + completed(member, DAY.plusDays(1).atStartOfDay(), DAY.plusDays(1).atTime(0, 20), "99.00"); + completed(other, DAY.atTime(10, 0), DAY.atTime(10, 45), "99.00"); + // 재계산 전 값은 일부러 틀리게 둔다 — 덮어쓰는지 본다. + dailyLogService.accumulateStats(member.getId(), DAY, 999, new BigDecimal("999.00")); + + dailyLogService.recomputeStats(member.getId(), DAY); + + Map row = row(); + assertThat(((Number) row.get("total_exercise_time")).intValue()).isEqualTo(10 + 5); + assertThat((BigDecimal) row.get("total_calories")).isEqualByComparingTo("50.75"); + } + + @Test + @DisplayName("recomputeStats — 남은 COMPLETED 가 없으면 0 으로 덮는다 (NULL 이 아니다)") + void recomputeWithNoSessionsWritesZero() { + dailyLogService.accumulateStats(member.getId(), DAY, 40, new BigDecimal("80.00")); + + dailyLogService.recomputeStats(member.getId(), DAY); + + Map row = row(); + assertThat(((Number) row.get("total_exercise_time")).intValue()).isZero(); + assertThat((BigDecimal) row.get("total_calories")).isEqualByComparingTo("0"); + } + + private Map row() { + return jdbcTemplate.queryForMap( + "SELECT memo, mood, total_exercise_time, total_calories FROM daily_logs WHERE member_id = ? AND log_date = ?", + member.getId(), DAY); + } + + private void completed(Member owner, LocalDateTime start, LocalDateTime end, String calories) { + session(owner, Status.COMPLETED, start, end, calories); + } + + private void session(Member owner, Status status, LocalDateTime start, LocalDateTime end, String calories) { + Session saved = sessionRepository.saveAndFlush(Session.builder() + .member(owner).exercise(exercise).startTime(start).endTime(end) + .status(status).totalReps(5).difficultyLevel(1) + .caloriesBurned(new BigDecimal(calories)).build()); + assertThat(saved.getId()).isNotNull(); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/exercise/FeedbackLogFkRaceTest.java b/backend/src/test/java/com/shadowfit/service/exercise/FeedbackLogFkRaceTest.java new file mode 100644 index 00000000..4240d9ef --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/exercise/FeedbackLogFkRaceTest.java @@ -0,0 +1,148 @@ +package com.shadowfit.service.exercise; + +import com.google.protobuf.util.Timestamps; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.grpc.FeedbackBatchRequest; +import com.shadowfit.grpc.FeedbackEvent; +import com.shadowfit.model.exercise.Category; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.CategoryRepository; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import com.shadowfit.support.MySqlContainerSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; + +/** + * {@link FeedbackLogService#saveBatch} 의 MySQL 쪽 판정을 실제 MySQL 에서 확인한다. + * + *

① FK 위반 → SESSION_NOT_FOUND. 서비스는 «존재 검사 뒤 INSERT 사이에 세션이 사라진» + * 경우를 FK 위반으로 알아채 {@code SESSION_NOT_FOUND} 로 답한다. 판정은 MySQL 벤더 코드 + * {@code 1452}(ER_NO_REFERENCED_ROW_2) 또는 H2·PG 의 SQLState 로 한다. 기본 테스트는 H2 라 + * SQLState 쪽만 돌았고, MySQL 분기 — 특히 {@code rewriteBatchedStatements=true} 로 배치가 + * 재작성될 때 {@code BatchUpdateException} 의 원인 체인에 1452 가 실제로 실리는가 — 는 검증된 적이 + * 없었다. 여기서 틀리면 운영에서 세션 소멸이 FK 가 아닌 위반으로 분류돼 500 이 된다. + * + *

② 삽입 건수. 반환값은 배치 전후 COUNT 차이다(#219 — 재작성된 배치는 행별 결과를 + * -2 로 준다). 운영과 같은 URL 옵션({@code MySqlContainerSupport}) 위에서 재전송 흡수 건수가 맞는지 본다. + * + *

레이스를 타이밍에 맡기지 않는다({@code SignupUsernameRaceTest} 와 같은 방식). 존재 검사 + * ({@code findById})를 스파이로 잡아 없는 세션 id 에도 세션을 돌려주게 만든다 — 검사를 통과한 + * 직후 세션이 지워진 상태와 DB 가 보는 것이 정확히 같다. + */ +@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"}) +@ActiveProfiles("race") +@Transactional +@DisplayName("피드백 배치 — MySQL 1452 번역·중복 흡수 건수 (실 MySQL)") +class FeedbackLogFkRaceTest extends MySqlContainerSupport { + + /** 존재하지 않는 세션 id — AUTO_INCREMENT 가 여기까지 올 일은 없다. */ + private static final long GHOST_SESSION_ID = Long.MAX_VALUE - 7; + + @Autowired private FeedbackLogService feedbackLogService; + @Autowired private MemberRepository memberRepository; + @Autowired private CategoryRepository categoryRepository; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockitoSpyBean private SessionRepository sessionRepository; + + private Session session; + + @BeforeEach + void setUp() { + Member member = memberRepository.saveAndFlush(Member.builder() + .email("feedback-fk-race@test.local").username("feedback-fk-race").password("x") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + Category category = categoryRepository.saveAndFlush(Category.builder().name("FEEDBACK_FK_RACE").build()); + // 멘트 템플릿이 없는 종목 — 유형 지원 검사가 «제한 없음» 으로 통과한다(검증 대상이 아니다). + Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("feedback-fk-race").category(category).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + session = sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise).startTime(LocalDateTime.now().minusMinutes(5)) + .status(Status.IN_PROGRESS).totalReps(0).difficultyLevel(1).build()); + } + + @Test + @DisplayName("검사 뒤 세션이 사라졌으면 MySQL 1452 가 SESSION_NOT_FOUND 로 번역된다") + void vanishedSession_isTranslatedToSessionNotFound() { + // 검사는 «있다» 고 답하지만 DB 에는 그 id 의 세션이 없다 — FK 가 INSERT 를 거부한다. + doReturn(Optional.of(session)).when(sessionRepository).findById(GHOST_SESSION_ID); + + assertThatThrownBy(() -> feedbackLogService.saveBatch(batch(GHOST_SESSION_ID, 1, 2))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); + } + + @Test + @DisplayName("재작성 배치에서도 삽입 건수가 맞다 — 재전송은 0 으로 센다") + void insertedCount_isCorrectUnderRewrittenBatch() { + FeedbackBatchRequest request = batch(session.getId(), 1, 2); + + int first = feedbackLogService.saveBatch(request); + int resent = feedbackLogService.saveBatch(request); + + assertThat(first).isEqualTo(2); + assertThat(resent).as("같은 배치 재전송은 uk_session_rep 가 흡수한다").isZero(); + assertThat(countRows()).isEqualTo(2); + } + + @Test + @DisplayName("재전송에 새 이벤트가 섞여도 새 것만 센다") + void insertedCount_resendMixedWithNew() { + feedbackLogService.saveBatch(batch(session.getId(), 1, 2)); + + int second = feedbackLogService.saveBatch(batch(session.getId(), 1, 2, 3)); + + assertThat(second).isEqualTo(1); + assertThat(countRows()).isEqualTo(3); + } + + // ⚠️ «한 배치 안에» 같은 (rep, 유형)이 두 번 있는 경우는 여기서 고정하지 않는다 — 실 MySQL 에서 + // 재작성된 multi-row INSERT 가 에러 1869(Auto-increment value in UPDATE conflicts…)로 통째로 + // 실패한다(2026-09-24 이 테스트를 쓰다 발견, #816). 지금 AI 는 배치를 항상 1건으로 보내 + // (ai-server pose.py flush_pending_feedback) 운영 경로에서는 나지 않는다. + + private int countRows() { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM session_feedback_logs WHERE session_id = ?", Integer.class, session.getId()); + } + + private static FeedbackBatchRequest batch(long sessionId, int... reps) { + FeedbackBatchRequest.Builder builder = FeedbackBatchRequest.newBuilder() + .setSessionId(sessionId).setSetNo(1).setIsFinal(false); + for (int rep : reps) { + builder.addEvents(FeedbackEvent.newBuilder() + .setFeedbackType("KNEE_OUT") + .setRepNumber(rep) + .setSyncRateAtTrigger(55.0) + .setOccurredAt(Timestamps.fromMillis(System.currentTimeMillis()))); + } + return builder.build(); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/exercise/PoseDataPartitionMaintenanceRaceTest.java b/backend/src/test/java/com/shadowfit/service/exercise/PoseDataPartitionMaintenanceRaceTest.java new file mode 100644 index 00000000..be3284ed --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/exercise/PoseDataPartitionMaintenanceRaceTest.java @@ -0,0 +1,175 @@ +package com.shadowfit.service.exercise; + +import com.shadowfit.support.MySqlContainerSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link PoseDataPartitionScheduler} 가 실제 MySQL 에서 DDL 을 실행해 보는 테스트. + * + *

{@code PoseDataPartitionSchedulerTest} 는 «무엇을 드롭/생성할지» 결정 로직만 본다(목 JdbcTemplate). + * 그 SQL 이 MySQL 에서 실제로 통과하는지 — {@code REORGANIZE PARTITION pfuture INTO (...)} 문법, + * {@code UNIX_TIMESTAMP('yyyy-MM-dd 00:00:00')} 경계식, {@code information_schema.partitions} 조회 — + * 는 한 번도 실행된 적이 없었다. 되돌릴 수 없는 작업(DROP PARTITION)이라 «돌려 보니 문법 오류» 를 + * 운영 새벽 4시에 처음 알게 되는 구조였다. + * + *

격리. race 테스트들은 컨테이너 하나를 같이 쓴다. 실제 {@code pose_data} 의 파티션을 + * 지우면 다른 테스트가 영향을 받으므로, 같은 컨테이너에 스크래치 스키마를 만들고 + * {@code CREATE TABLE ... LIKE} 로 Flyway 가 만든 진짜 정의(컬럼·PK·유니크 키)를 복사한 뒤 + * 파티션만 «오늘 기준 상대 월» 로 다시 짠다. 스케줄러는 {@code DATABASE()} 와 무한정 테이블명 + * {@code pose_data} 를 쓰므로 스크래치 스키마를 기본 DB 로 하는 커넥션을 주면 그대로 그쪽을 본다 — + * main 코드는 바꾸지 않는다. + * + *

⚠️ 월 경계: 스케줄러는 실행 시점의 {@code YearMonth.now(Asia/Seoul)} 를 쓴다. 테스트가 정확히 + * 자정 월 넘김에 걸리면 기대값과 한 달 어긋날 수 있다(드물고, 재실행으로 사라진다). + */ +@SpringBootTest(properties = {"scheduling.enabled=false", "grpc.server.port=-1"}) +@ActiveProfiles("race") +@DisplayName("pose_data 파티션 유지보수 — DROP/REORGANIZE 실제 실행 (실 MySQL)") +class PoseDataPartitionMaintenanceRaceTest extends MySqlContainerSupport { + + private static final String SCRATCH = "pose_partition_it"; + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter BOUND = DateTimeFormatter.ofPattern("yyyy-MM-dd 00:00:00"); + // 스케줄러 기본값과 같게 둔다(@Value 기본값: retention-buffer-months=1, lookahead-months=2). + private static final int RETENTION_BUFFER_MONTHS = 1; + private static final int LOOKAHEAD_MONTHS = 2; + + /** Flyway 를 돌리기 위해 컨텍스트를 띄운다 — 복사 원본 {@code pose_data} 가 거기서 만들어진다. */ + @Autowired private JdbcTemplate appJdbc; + + private JdbcTemplate root; + private JdbcTemplate scratch; + private YearMonth now; + + @BeforeEach + void setUp() { + now = YearMonth.now(SEOUL); + root = new JdbcTemplate(dataSource(MYSQL.getDatabaseName())); + root.execute("DROP DATABASE IF EXISTS " + SCRATCH); + root.execute("CREATE DATABASE " + SCRATCH); + String source = MYSQL.getDatabaseName() + ".pose_data"; + assertThat(appJdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name = 'pose_data'", Integer.class)) + .as("Flyway 가 만든 복사 원본이 있어야 한다").isEqualTo(1); + root.execute("CREATE TABLE " + SCRATCH + ".pose_data LIKE " + source); + + // 파티션 배치: 이름 패턴 밖(p_legacy) · now-3 · now-2 · now-1 · now · pfuture + // → 기대: now-3·now-2 드롭(보존은 now-1 부터), p_legacy 는 파싱 실패라 유지, + // now+1·now+2 생성(lookahead 2), pfuture 는 끝에 그대로. + root.execute("ALTER TABLE " + SCRATCH + ".pose_data PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (" + + "PARTITION p_legacy VALUES LESS THAN (" + upper(now.minusMonths(4)) + "), " + + partition(now.minusMonths(3)) + ", " + + partition(now.minusMonths(2)) + ", " + + partition(now.minusMonths(1)) + ", " + + partition(now) + ", " + + "PARTITION pfuture VALUES LESS THAN MAXVALUE)"); + + scratch = new JdbcTemplate(dataSource(SCRATCH)); + insertRowIn(now.minusMonths(5), 1); // p_legacy + insertRowIn(now.minusMonths(3), 2); // 드롭될 달 + insertRowIn(now.minusMonths(1), 3); // 버퍼 — 남아야 한다 + insertRowIn(now, 4); // 이번 달 — 남아야 한다 + } + + @AfterEach + void tearDown() { + root.execute("DROP DATABASE IF EXISTS " + SCRATCH); + } + + @Test + @DisplayName("만료 달만 DROP, 미래 달은 REORGANIZE 로 생성 — 행은 만료 달 것만 사라진다") + void maintain_dropsExpiredAndCreatesFuture() { + scheduler().checkAndMaintainPartitions(); + + assertThat(partitionNames()).containsExactly( + "p_legacy", + name(now.minusMonths(1)), + name(now), + name(now.plusMonths(1)), + name(now.plusMonths(2)), + "pfuture"); + assertThat(sessionIds()) + .as("드롭된 달(now-3)의 행만 사라지고, 이름 패턴 밖 파티션·버퍼·이번 달 행은 남는다") + .containsExactlyInAnyOrder(1L, 3L, 4L); + } + + @Test + @DisplayName("두 번 돌려도 결과가 같다 — 매일 도는 잡이라 멱등이어야 한다") + void maintain_isIdempotent() { + PoseDataPartitionScheduler scheduler = scheduler(); + scheduler.checkAndMaintainPartitions(); + List afterFirst = partitionNames(); + + scheduler.checkAndMaintainPartitions(); + + assertThat(partitionNames()).isEqualTo(afterFirst); + } + + @Test + @DisplayName("새로 만든 미래 파티션 경계가 맞다 — 다음 달 1일 0시 행이 그 달 파티션에 들어간다") + void createdPartition_boundaryIsMonthStart() { + scheduler().checkAndMaintainPartitions(); + YearMonth next = now.plusMonths(1); + insertRowIn(next, 5); + + // information_schema 의 table_rows 는 추정치라 PARTITION 절로 직접 센다. + Integer rows = scratch.queryForObject( + "SELECT COUNT(*) FROM pose_data PARTITION (" + name(next) + ") WHERE session_id = 5", Integer.class); + assertThat(rows).as("다음 달 1일 0시 행은 %s 에 들어가야 한다", name(next)).isEqualTo(1); + } + + private PoseDataPartitionScheduler scheduler() { + return new PoseDataPartitionScheduler(scratch, RETENTION_BUFFER_MONTHS, LOOKAHEAD_MONTHS); + } + + /** 그 달 1일 0시 행 — 파티션 경계 바로 위라 경계식이 틀리면 옆 파티션으로 간다. */ + private void insertRowIn(YearMonth month, long sessionId) { + scratch.update("INSERT INTO pose_data (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, created_at) " + + "VALUES (?, 1, 0.0, JSON_ARRAY(), 50.00, ?)", + sessionId, month.atDay(1).atStartOfDay()); + } + + private List partitionNames() { + return scratch.queryForList("SELECT partition_name FROM information_schema.partitions " + + "WHERE table_schema = DATABASE() AND table_name = 'pose_data' ORDER BY partition_ordinal_position", String.class); + } + + private List sessionIds() { + return scratch.queryForList("SELECT session_id FROM pose_data", Long.class); + } + + private static String partition(YearMonth month) { + return "PARTITION " + name(month) + " VALUES LESS THAN (" + upper(month) + ")"; + } + + private static String upper(YearMonth month) { + LocalDateTime bound = month.plusMonths(1).atDay(1).atStartOfDay(); + return "UNIX_TIMESTAMP('" + bound.format(BOUND) + "')"; + } + + private static String name(YearMonth month) { + return "p%04d_%02d".formatted(month.getYear(), month.getMonthValue()); + } + + /** root 로 붙는다 — 앱 계정은 자기 스키마 권한뿐이라 CREATE DATABASE 를 못 한다. */ + private static DriverManagerDataSource dataSource(String database) { + String url = MYSQL.getJdbcUrl().replaceFirst("/" + MYSQL.getDatabaseName() + "(\\?|$)", "/" + database + "$1"); + return new DriverManagerDataSource(url, "root", MYSQL.getPassword()); + } +} diff --git a/backend/src/test/java/com/shadowfit/support/MySqlContainerSupport.java b/backend/src/test/java/com/shadowfit/support/MySqlContainerSupport.java index ffe35dde..52d0e363 100644 --- a/backend/src/test/java/com/shadowfit/support/MySqlContainerSupport.java +++ b/backend/src/test/java/com/shadowfit/support/MySqlContainerSupport.java @@ -37,8 +37,10 @@ disabledReason = "-Dmysql.container=false 로 실 MySQL 테스트를 명시적으로 껐다") public abstract class MySqlContainerSupport { + // protected: 앱 계정(test)은 자기 스키마 권한뿐이라, 스크래치 스키마가 필요한 테스트 + // (PoseDataPartitionMaintenanceRaceTest)가 root 로 붙을 때 URL·비밀번호를 여기서 얻는다. @ServiceConnection - static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.0") + protected static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.0") .withCommand( "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci",