test(recommendation): 응원 크리에이터 동시성 테스트를 안정화한다

This commit is contained in:
2026-08-03 20:53:28 +09:00
parent 033044b4de
commit 20f73a89f0
3 changed files with 66 additions and 75 deletions

View File

@@ -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<RecommendationSnapshotRecord>(), first.get(5, TimeUnit.SECONDS))
assertEquals(emptyList<RecommendationSnapshotRecord>(), 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<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 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<RecommendationSnapshotRecord>()
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(