diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md index 2c0c13b1..41623360 100644 --- a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md @@ -4,10 +4,10 @@ | 항목 | 내용 | |---|---| -| 상태 | 3차 Phase별 리뷰 완료 | -| 확정일 | 2026-07-31 | +| 상태 | Jenkins 회귀 수정 완료 | +| 확정일 | 2026-08-03 | | 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 | -| 현재 Phase | Phase 1~7 3차 리뷰 완료 | +| 현재 Phase | Phase 1~7 및 `P5-R2` 완료 | | 현재 활성 Goal | 없음 | | 다음 Goal | 없음 | @@ -318,6 +318,20 @@ - [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다. - [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다. +#### Task R5.2 Jenkins single-flight 테스트 scheduling race 제거 + +**Goal 실행 `P5-R2`:** 두 동시 요청이 pending refresh future를 공유하는 계약을 worker 완료 순서에 의존하지 않고 검증한다. + +- **시작 조건:** Jenkins에서 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests`의 두 번째 request `Future.get(...)`이 `TimeoutException`으로 실패하고, `REV-P5-002`가 테스트 scheduling race로 확정됨. +- **완료 증거:** Jenkins 실패 원인 기록, 결정적 테스트 최소 수정, fallback focused test 반복 통과, 직접 영향 회귀·format·문서 명령·diff 검증 결과 누적. +- **범위 밖:** `RecommendationSnapshotFallbackService` production 동작, worker 수, 300ms lock 대기와 1,500ms 홈 대기, 다른 fallback 섹션 변경. + +- [x] **RED:** Jenkins 실패의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 focused test 통과를 함께 기록해 worker scheduling에 따라 결과가 달라지는 기존 테스트를 확인한다. +- [x] **RED 확인:** `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'`의 로컬 통과와 Jenkins 실패를 대조해 비결정성을 확인한다. +- [x] **GREEN:** 수동 `CapturingExecutor.runNext()` 완료 경쟁 대신 refresh worker를 latch로 pending 상태에 유지한다. 두 요청이 홈 대기 timeout으로 반환할 때까지 refresh를 완료하지 않고 `refreshCheerCreatorSnapshots(...)` 호출이 1회인지 검증한다. +- [x] **GREEN 확인:** 같은 focused test를 반복 실행하고 `RecommendationSnapshotFallbackServiceTest` 전체를 실행해 single-flight, timeout 후 worker 지속, lock 내부 double-check가 모두 통과하는지 확인한다. +- [x] **REFACTOR/GATE:** 사용하지 않는 test helper/import만 제거하고 `ktlintCheck`, `./gradlew tasks --all`, `git diff --check` 결과를 전체 검증 기록에 누적한다. + --- ### Phase 6: API 회귀와 최종 검증 @@ -440,6 +454,8 @@ git diff --check - Non-Goals: Task 4.1, Task 6.1, Task 6.3, Task 7.1에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 16명 밖 후보 보충 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다. ## 전체 검증 기록 +- 2026-08-03: `P5-R2` GREEN/GATE로 수동 `CapturingExecutor` 완료 경쟁을 제거하고, 실제 worker를 latch로 pending 상태에 유지한 채 두 요청의 worker task 제출과 `refreshCheerCreatorSnapshots(...)` 호출이 각각 1회인지 검증하도록 수정했다. focused test는 최초 1회와 연속 10회 모두 통과했고, `RecommendationSnapshotFallbackServiceTest` 전체, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `./gradlew cleanTest test`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. 전체 테스트는 7분 14초 소요됐다. Oracle 고강도 리뷰도 correctness·결정성·thread cleanup·production 무변경 판단에 blocker 없이 승인했다. +- 2026-08-03: `P5-R2` RED 확인으로 Jenkins의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'` `BUILD SUCCESSFUL`을 대조했다. 같은 코드가 worker scheduling에 따라 실패·성공하여 기존 테스트의 `taskSubmitted` latch가 두 요청의 동일 future 대기를 보장하지 않는 비결정성을 확인했다. - 2026-07-31: Phase 1~7 3차 리뷰로 PRD·plan-task·현재 코드·테스트를 정적으로 대조했다. 이전 리뷰에서 보완한 문서 정합성, 종료 라이브 데뷔 이력, `CHEER_CREATOR` refresh 실패 로그, fallback single-flight·double-check, 공개 응답 3개 필드, 본인·활성 팔로우 제외가 현재 구현과 회귀 테스트에 유지됨을 확인했다. 각 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 3차 리뷰로 별도 누적했다. 추가 확정 발견 사항이 없어 신규 회귀 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 뒤 승인된 동일 명령에서 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-31: `P3-R2`로 `CHEER_CREATOR` 데뷔 CTE가 채널명이 있는 종료 라이브를 데뷔 이력으로 인정하도록 복구했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut`가 `AssertionFailedError`로 실패하는 것을 확인했다. 이후 `findCheerCreatorSnapshots(...)` 라이브 branch에서 `lr.is_active = true`만 제거했고, 같은 repository focused test는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다. - 2026-07-31: `P1-R2`로 상단 후속 변경 상태를 현재 PRD 전체, Phase 1~7 판정, 실제 미완료 Goal `P3-R2` 기준으로 정리했다. 문서 정합성 복구만 수행해 TDD 예외로 처리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md index 48996e24..b6de91ac 100644 --- a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md @@ -32,6 +32,7 @@ | ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | |---|---|---|---|---|---| | `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` | +| `REV-P5-002` | Medium | 보완 완료 | single-flight 테스트가 worker 완료 순서에 의존해 Jenkins에서 timeout된다 | Task R5.2 | `P5-R2` | ## 6. 발견 사항 상세 @@ -102,3 +103,16 @@ sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하 - **plan·goal 전환:** 전환 항목 없음. - **최종 결론:** 확정 발견 사항 없음. - **남은 항목:** 없음. + +--- + +## 11. 4차 리뷰 기록 — 2026-08-03 + +- **리뷰 대상:** Phase 5 / Task R5.1의 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests` Jenkins 실패. +- **검토 근거:** Jenkins `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`, `RecommendationSnapshotFallbackService.kt:141-149`, 테스트의 `CapturingExecutor`와 latch 흐름, 로컬 `cleanTest` focused test 실행, JDK 17 `CompletableFuture`·`CountDownLatch` 계약. +- **확정 발견 사항:** `REV-P5-002`. `taskSubmitted` latch는 worker task가 executor에 전달됐다는 사실만 보장하며, refresh future가 `refreshFutures`에 게시됐거나 두 요청이 같은 future에서 대기 중임을 보장하지 않는다. Jenkins에서 첫 worker가 먼저 완료되면 reference가 제거되고 두 번째 요청은 실행되지 않는 새 captured task를 기다려 바깥 `get(1s)`에서 timeout된다. +- **판정:** production single-flight 결함 근거는 없다. timeout 확대는 경쟁을 숨기므로 제외하고, refresh를 latch로 pending 상태에 유지한 채 두 요청의 refresh 호출 횟수 1회를 검증하도록 테스트만 수정한다. +- **plan·goal 전환:** `plan-task.md` Phase 5에 Task R5.2 / `P5-R2`를 추가했다. +- **검증 결과:** focused test 최초 1회와 연속 10회, `RecommendationSnapshotFallbackServiceTest` 전체, `ktlintCheck`, `tasks --all`, 전체 `cleanTest test`, `git diff --check`가 모두 통과했다. Oracle 고강도 리뷰도 blocker 없이 승인했다. +- **최종 결론:** `P5-R2` 보완 완료. +- **남은 항목:** 없음. diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt index 1619ef09..f1649bef 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt @@ -14,8 +14,8 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.Future -import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger class RecommendationSnapshotFallbackServiceTest { @Test @@ -265,10 +265,18 @@ class RecommendationSnapshotFallbackServiceTest { homeWaitMillis = 50 ) - val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) - assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) - allowRefreshComplete.countDown() - executor.shutdown() + val first = try { + val result = service.refreshCheerCreatorIfMissing( + offset = 0, + limit = 16, + nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + ) + assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) + result + } finally { + allowRefreshComplete.countDown() + executor.shutdown() + } assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) val second = service.refreshCheerCreatorIfMissing( offset = 0, @@ -283,46 +291,43 @@ class RecommendationSnapshotFallbackServiceTest { @Test @DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다") fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() { - val existsSnapshotEntered = CountDownLatch(2) - val existsSnapshotReturned = CountDownLatch(2) - val snapshotPort = FakeRecommendationFallbackSnapshotPort( - existsSnapshotEntered = existsSnapshotEntered, - existsSnapshotReturned = existsSnapshotReturned - ) - val refreshService = FastCheerRefreshService(snapshotPort) + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val refreshStarted = CountDownLatch(1) + val allowRefreshComplete = CountDownLatch(1) + val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete) val redissonClient = Mockito.mock(RedissonClient::class.java) val lock = Mockito.mock(RLock::class.java) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) - val workerExecutor = CapturingExecutor() + val workerTaskCount = AtomicInteger() + val workerPool = Executors.newFixedThreadPool(2) + val workerExecutor = Executor { command -> + workerTaskCount.incrementAndGet() + workerPool.execute(command) + } val requestExecutor = Executors.newFixedThreadPool(2) val service = RecommendationSnapshotFallbackService( snapshotPort, refreshService, redissonClient, workerExecutor, - homeWaitMillis = 1_000 + homeWaitMillis = 50 ) val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) try { val first = requestExecutor.submitCheerRefresh(service, nowUtc) val second = requestExecutor.submitCheerRefresh(service, nowUtc) - assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS)) - assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS)) - assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS)) - assertEquals(false, first.isDone) - assertEquals(false, second.isDone) - assertEquals(1, workerExecutor.taskCount) - - workerExecutor.runNext() - - assertEquals(listOf(99L), first.get(1, TimeUnit.SECONDS).map { it.targetId }) - assertEquals(listOf(99L), second.get(1, TimeUnit.SECONDS).map { it.targetId }) + assertEquals(true, refreshStarted.await(5, TimeUnit.SECONDS)) + assertEquals(emptyList(), first.get(5, TimeUnit.SECONDS)) + assertEquals(emptyList(), second.get(5, TimeUnit.SECONDS)) + assertEquals(1, workerTaskCount.get()) assertEquals(1, refreshService.cheerRefreshCount) } finally { + allowRefreshComplete.countDown() requestExecutor.shutdownNow() + workerPool.shutdownNow() } } @@ -408,23 +413,6 @@ class RecommendationSnapshotFallbackServiceTest { } } -private class CapturingExecutor : Executor { - val taskSubmitted = CountDownLatch(1) - private val tasks = LinkedBlockingQueue() - - val taskCount: Int - get() = tasks.size - - override fun execute(command: Runnable) { - tasks.add(command) - taskSubmitted.countDown() - } - - fun runNext() { - tasks.poll(1, TimeUnit.SECONDS)!!.run() - } -} - private class BlockingCheerRefreshService( private val snapshotPort: RecommendationSnapshotPort, private val refreshStarted: CountDownLatch, @@ -438,26 +426,7 @@ private class BlockingCheerRefreshService( override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { cheerRefreshCount += 1 refreshStarted.countDown() - allowRefreshComplete.await(1, TimeUnit.SECONDS) - snapshotPort.replaceSnapshots( - RecommendedSectionType.CHEER_CREATOR, - LocalDateTime.of(2026, 7, 9, 14, 59, 59), - listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L)) - ) - return 1 - } -} - -private class FastCheerRefreshService( - private val snapshotPort: RecommendationSnapshotPort -) : RecommendationSnapshotRefreshService( - snapshotPort, - Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) -) { - var cheerRefreshCount: Int = 0 - - override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { - cheerRefreshCount += 1 + allowRefreshComplete.await() snapshotPort.replaceSnapshots( RecommendedSectionType.CHEER_CREATOR, LocalDateTime.of(2026, 7, 9, 14, 59, 59), @@ -496,10 +465,7 @@ private class BlockingAiAndFastCheerRefreshService( } } -private class FakeRecommendationFallbackSnapshotPort( - private val existsSnapshotEntered: CountDownLatch? = null, - private val existsSnapshotReturned: CountDownLatch? = null -) : RecommendationSnapshotPort { +private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort { private val snapshots = mutableListOf() override fun findLatestSnapshots( @@ -531,12 +497,7 @@ private class FakeRecommendationFallbackSnapshotPort( } override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { - existsSnapshotEntered?.countDown() - existsSnapshotEntered?.await(1, TimeUnit.SECONDS) - val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } - existsSnapshotReturned?.countDown() - existsSnapshotReturned?.await(1, TimeUnit.SECONDS) - return exists + return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } } override fun replaceSnapshots(