test #444

Merged
klaus merged 2 commits from test into main 2026-08-05 07:45:06 +00:00
3 changed files with 66 additions and 75 deletions
Showing only changes of commit 20f73a89f0 - Show all commits

View File

@@ -4,10 +4,10 @@
| 항목 | 내용 | | 항목 | 내용 |
|---|---| |---|---|
| 상태 | 3차 Phase별 리뷰 완료 | | 상태 | Jenkins 회귀 수정 완료 |
| 확정일 | 2026-07-31 | | 확정일 | 2026-08-03 |
| 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 | | 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 |
| 현재 Phase | Phase 1~7 3차 리뷰 완료 | | 현재 Phase | Phase 1~7 `P5-R2` 완료 |
| 현재 활성 Goal | 없음 | | 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 | | 다음 Goal | 없음 |
@@ -318,6 +318,20 @@
- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다. - [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다.
- [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다. - [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 회귀와 최종 검증 ### 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 제외를 확인한다. - 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: 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: `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`로 통과했다. - 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`로 통과했다.

View File

@@ -32,6 +32,7 @@
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | | ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` | | `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. 발견 사항 상세 ## 6. 발견 사항 상세
@@ -102,3 +103,16 @@ sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하
- **plan·goal 전환:** 전환 항목 없음. - **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` 보완 완료.
- **남은 항목:** 없음.

View File

@@ -14,8 +14,8 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor import java.util.concurrent.Executor
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future import java.util.concurrent.Future
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class RecommendationSnapshotFallbackServiceTest { class RecommendationSnapshotFallbackServiceTest {
@Test @Test
@@ -265,10 +265,18 @@ class RecommendationSnapshotFallbackServiceTest {
homeWaitMillis = 50 homeWaitMillis = 50
) )
val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) 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)) assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
result
} finally {
allowRefreshComplete.countDown() allowRefreshComplete.countDown()
executor.shutdown() executor.shutdown()
}
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
val second = service.refreshCheerCreatorIfMissing( val second = service.refreshCheerCreatorIfMissing(
offset = 0, offset = 0,
@@ -283,46 +291,43 @@ class RecommendationSnapshotFallbackServiceTest {
@Test @Test
@DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다") @DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다")
fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() { fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() {
val existsSnapshotEntered = CountDownLatch(2) val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val existsSnapshotReturned = CountDownLatch(2) val refreshStarted = CountDownLatch(1)
val snapshotPort = FakeRecommendationFallbackSnapshotPort( val allowRefreshComplete = CountDownLatch(1)
existsSnapshotEntered = existsSnapshotEntered, val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
existsSnapshotReturned = existsSnapshotReturned
)
val refreshService = FastCheerRefreshService(snapshotPort)
val redissonClient = Mockito.mock(RedissonClient::class.java) val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java) val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).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 requestExecutor = Executors.newFixedThreadPool(2)
val service = RecommendationSnapshotFallbackService( val service = RecommendationSnapshotFallbackService(
snapshotPort, snapshotPort,
refreshService, refreshService,
redissonClient, redissonClient,
workerExecutor, workerExecutor,
homeWaitMillis = 1_000 homeWaitMillis = 50
) )
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
try { try {
val first = requestExecutor.submitCheerRefresh(service, nowUtc) val first = requestExecutor.submitCheerRefresh(service, nowUtc)
val second = requestExecutor.submitCheerRefresh(service, nowUtc) val second = requestExecutor.submitCheerRefresh(service, nowUtc)
assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS)) assertEquals(true, refreshStarted.await(5, TimeUnit.SECONDS))
assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS)) assertEquals(emptyList<RecommendationSnapshotRecord>(), first.get(5, TimeUnit.SECONDS))
assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS)) assertEquals(emptyList<RecommendationSnapshotRecord>(), second.get(5, TimeUnit.SECONDS))
assertEquals(false, first.isDone) assertEquals(1, workerTaskCount.get())
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(1, refreshService.cheerRefreshCount) assertEquals(1, refreshService.cheerRefreshCount)
} finally { } finally {
allowRefreshComplete.countDown()
requestExecutor.shutdownNow() requestExecutor.shutdownNow()
workerPool.shutdownNow()
} }
} }
@@ -408,23 +413,6 @@ class RecommendationSnapshotFallbackServiceTest {
} }
} }
private class CapturingExecutor : Executor {
val taskSubmitted = CountDownLatch(1)
private val tasks = LinkedBlockingQueue<Runnable>()
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 class BlockingCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort, private val snapshotPort: RecommendationSnapshotPort,
private val refreshStarted: CountDownLatch, private val refreshStarted: CountDownLatch,
@@ -438,26 +426,7 @@ private class BlockingCheerRefreshService(
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
cheerRefreshCount += 1 cheerRefreshCount += 1
refreshStarted.countDown() refreshStarted.countDown()
allowRefreshComplete.await(1, TimeUnit.SECONDS) allowRefreshComplete.await()
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
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR, RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59), LocalDateTime.of(2026, 7, 9, 14, 59, 59),
@@ -496,10 +465,7 @@ private class BlockingAiAndFastCheerRefreshService(
} }
} }
private class FakeRecommendationFallbackSnapshotPort( private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort {
private val existsSnapshotEntered: CountDownLatch? = null,
private val existsSnapshotReturned: CountDownLatch? = null
) : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>() private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots( override fun findLatestSnapshots(
@@ -531,12 +497,7 @@ private class FakeRecommendationFallbackSnapshotPort(
} }
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
existsSnapshotEntered?.countDown() return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
existsSnapshotEntered?.await(1, TimeUnit.SECONDS)
val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
existsSnapshotReturned?.countDown()
existsSnapshotReturned?.await(1, TimeUnit.SECONDS)
return exists
} }
override fun replaceSnapshots( override fun replaceSnapshots(