From 391acf9ee53a8524b1aabf077686019cf92567a5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 06:06:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=B6=94=EC=B2=9C=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20fallback=EC=9D=84=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiCharacterSnapshotFallbackPort.kt | 13 + .../AiCharacterSnapshotFallbackService.kt | 137 ------ .../RecommendationSnapshotFallbackService.kt | 222 ++++++++++ .../AiCharacterSnapshotFallbackServiceTest.kt | 351 ---------------- ...commendationSnapshotFallbackServiceTest.kt | 390 ++++++++++++++++++ 5 files changed, 625 insertions(+), 488 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.kt delete mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt delete mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.kt new file mode 100644 index 00000000..73f1f5b6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.recommendation.application + +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import java.time.LocalDateTime +import java.time.ZoneOffset + +interface AiCharacterSnapshotFallbackPort { + fun refreshIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + ): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt deleted file mode 100644 index cc53b9e0..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt +++ /dev/null @@ -1,137 +0,0 @@ -package kr.co.vividnext.sodalive.v2.recommendation.application - -import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord -import org.redisson.api.RedissonClient -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.support.TransactionTemplate -import java.time.LocalDateTime -import java.time.ZoneOffset -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Executor -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import java.util.concurrent.atomic.AtomicReference -import javax.annotation.PreDestroy - -@Service -class AiCharacterSnapshotFallbackService( - private val snapshotPort: RecommendationSnapshotPort, - private val refreshService: RecommendationSnapshotRefreshService, - private val redissonClient: RedissonClient, - executor: Executor? = null, - private val homeWaitMillis: Long = HOME_WAIT_MILLIS, - transactionManager: PlatformTransactionManager? = null -) : AiCharacterSnapshotFallbackPort { - private val log = LoggerFactory.getLogger(javaClass) - private val ownedExecutor: ExecutorService? = if (executor == null) Executors.newSingleThreadExecutor() else null - private val workerExecutor: Executor = executor ?: ownedExecutor!! - private val refreshFuture = AtomicReference?>() - private val readTransactionTemplate = transactionManager?.let { - TransactionTemplate(it).also { template -> - template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW - template.isReadOnly = true - } - } - - override fun refreshIfMissing( - offset: Long, - limit: Int, - nowUtc: LocalDateTime - ): List { - val existing = findLatestSnapshots(offset, limit) - if (existing.isNotEmpty()) return existing - if (hasLatestSnapshot()) return findLatestSnapshots(offset, limit) - - val future = getOrStartRefresh(nowUtc) - return try { - future.get(homeWaitMillis, TimeUnit.MILLISECONDS) - findLatestSnapshots(offset, limit) - } catch (ex: TimeoutException) { - log.warn("event=ai_character_snapshot_fallback_timeout homeWaitMs={}", homeWaitMillis) - emptyList() - } catch (ex: InterruptedException) { - Thread.currentThread().interrupt() - log.warn("event=ai_character_snapshot_fallback_failure error={}", ex.message, ex) - emptyList() - } catch (ex: Exception) { - log.warn("event=ai_character_snapshot_fallback_failure error={}", ex.message, ex) - emptyList() - } - } - - private fun findLatestSnapshots(offset: Long, limit: Int): List { - return readSnapshotPort { - snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit) - } - } - - private fun hasLatestSnapshot(): Boolean { - return readSnapshotPort { - snapshotPort.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER) - } - } - - private fun readSnapshotPort(action: () -> T): T { - val template = readTransactionTemplate ?: return action() - return template.execute { action() } ?: action() - } - - @Synchronized - private fun getOrStartRefresh(nowUtc: LocalDateTime): CompletableFuture { - refreshFuture.get()?.let { return it } - - val newFuture = CompletableFuture.runAsync({ refreshInWorker(nowUtc) }, workerExecutor) - refreshFuture.set(newFuture) - newFuture.whenComplete { _, _ -> refreshFuture.compareAndSet(newFuture, null) } - return newFuture - } - - private fun refreshInWorker(nowUtc: LocalDateTime) { - val lock = redissonClient.getLock(LOCK_KEY) - try { - if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) { - log.info("event=ai_character_snapshot_fallback_lock_missed lockKey={} lockWaitMs={}", LOCK_KEY, LOCK_WAIT_MILLIS) - return - } - - log.info("event=ai_character_snapshot_fallback_lock_acquired lockKey={}", LOCK_KEY) - if (hasLatestSnapshot()) return - - log.info("event=ai_character_snapshot_fallback_refresh_start lockKey={}", LOCK_KEY) - val refreshedCount = refreshService.refreshAiCharacterSnapshots(nowUtc) - log.info("event=ai_character_snapshot_fallback_refresh_success refreshedCount={}", refreshedCount) - } catch (ex: Exception) { - log.warn("event=ai_character_snapshot_fallback_refresh_failure error={}", ex.message, ex) - } finally { - if (lock.isHeldByCurrentThread) { - lock.unlock() - } - } - } - - @PreDestroy - fun shutdown() { - ownedExecutor?.shutdown() - } - - companion object { - const val LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER" - private const val LOCK_WAIT_MILLIS = 300L - private const val HOME_WAIT_MILLIS = 1_500L - } -} - -interface AiCharacterSnapshotFallbackPort { - fun refreshIfMissing( - offset: Long, - limit: Int, - nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) - ): List -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt new file mode 100644 index 00000000..760ee662 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt @@ -0,0 +1,222 @@ +package kr.co.vividnext.sodalive.v2.recommendation.application + +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import org.redisson.api.RedissonClient +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicReference +import javax.annotation.PreDestroy + +@Primary +@Service +class RecommendationSnapshotFallbackService( + private val snapshotPort: RecommendationSnapshotPort, + private val refreshService: RecommendationSnapshotRefreshService, + private val redissonClient: RedissonClient, + executor: Executor? = null, + private val homeWaitMillis: Long = HOME_WAIT_MILLIS, + transactionManager: PlatformTransactionManager? = null +) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort { + private val log = LoggerFactory.getLogger(javaClass) + private val ownedExecutor: ExecutorService? = if (executor == null) { + Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS) + } else { + null + } + private val workerExecutor: Executor = executor ?: ownedExecutor!! + private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() + private val refreshFutures = ConcurrentHashMap?>>() + private val readTransactionTemplate = transactionManager?.let { + TransactionTemplate(it).also { template -> + template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + template.isReadOnly = true + } + } + + override fun refreshIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + return refreshIfMissing(target(RecommendedSectionType.AI_CHARACTER, AI_CHARACTER_LOCK_KEY), offset, limit, nowUtc) + } + + override fun refreshCheerCreatorIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + return refreshIfMissing(target(RecommendedSectionType.CHEER_CREATOR, CHEER_CREATOR_LOCK_KEY), offset, limit, nowUtc) + } + + private fun refreshIfMissing( + target: FallbackTarget, + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt + val existing = findSnapshots(target.sectionType, snapshotAt, offset, limit) + if (existing.isNotEmpty()) return existing + if (hasSnapshot(target.sectionType, snapshotAt)) return findSnapshots(target.sectionType, snapshotAt, offset, limit) + + val future = getOrStartRefresh(target, nowUtc) + return try { + future.get(homeWaitMillis, TimeUnit.MILLISECONDS) + findSnapshots(target.sectionType, snapshotAt, offset, limit) + } catch (ex: TimeoutException) { + log.warn( + "event=recommendation_snapshot_fallback_timeout sectionType={} homeWaitMs={}", + target.sectionType, + homeWaitMillis + ) + emptyList() + } catch (ex: InterruptedException) { + Thread.currentThread().interrupt() + log.warn( + "event=recommendation_snapshot_fallback_failure sectionType={} error={}", + target.sectionType, + ex.message, + ex + ) + emptyList() + } catch (ex: Exception) { + log.warn( + "event=recommendation_snapshot_fallback_failure sectionType={} error={}", + target.sectionType, + ex.message, + ex + ) + emptyList() + } + } + + private fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long, + limit: Int + ): List { + return readSnapshotPort { snapshotPort.findSnapshots(sectionType, snapshotAt, offset, limit) } + } + + private fun hasSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { + return readSnapshotPort { snapshotPort.existsSnapshot(sectionType, snapshotAt) } + } + + private fun readSnapshotPort(action: () -> T): T { + val template = readTransactionTemplate ?: return action() + return template.execute { action() } ?: action() + } + + @Synchronized + private fun getOrStartRefresh(target: FallbackTarget, nowUtc: LocalDateTime): CompletableFuture { + val reference = refreshFutures.computeIfAbsent(target.sectionType) { AtomicReference() } + reference.get()?.let { return it } + + val newFuture = CompletableFuture.runAsync({ refreshInWorker(target, nowUtc) }, workerExecutor) + reference.set(newFuture) + newFuture.whenComplete { _, _ -> reference.compareAndSet(newFuture, null) } + return newFuture + } + + private fun refreshInWorker(target: FallbackTarget, nowUtc: LocalDateTime) { + val lock = redissonClient.getLock(target.lockKey) + try { + if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) { + log.info( + "event=recommendation_snapshot_fallback_lock_missed sectionType={} lockKey={} lockWaitMs={}", + target.sectionType, + target.lockKey, + LOCK_WAIT_MILLIS + ) + return + } + + log.info( + "event=recommendation_snapshot_fallback_lock_acquired sectionType={} lockKey={}", + target.sectionType, + target.lockKey + ) + val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt + if (hasSnapshot(target.sectionType, snapshotAt)) return + + log.info( + "event=recommendation_snapshot_fallback_refresh_start sectionType={} lockKey={}", + target.sectionType, + target.lockKey + ) + val refreshedCount = refresh(target.sectionType, nowUtc) + log.info( + "event=recommendation_snapshot_fallback_refresh_success sectionType={} refreshedCount={}", + target.sectionType, + refreshedCount + ) + } catch (ex: Exception) { + log.warn( + "event=recommendation_snapshot_fallback_refresh_failure sectionType={} error={}", + target.sectionType, + ex.message, + ex + ) + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + + private fun refresh(sectionType: RecommendedSectionType, nowUtc: LocalDateTime): Int { + return when (sectionType) { + RecommendedSectionType.AI_CHARACTER -> refreshService.refreshAiCharacterSnapshots(nowUtc) + RecommendedSectionType.CHEER_CREATOR -> refreshService.refreshCheerCreatorSnapshots(nowUtc) + else -> error("Unsupported fallback sectionType: $sectionType") + } + } + + @PreDestroy + fun shutdown() { + ownedExecutor?.shutdown() + } + + private fun target(sectionType: RecommendedSectionType, lockKey: String): FallbackTarget { + return FallbackTarget(sectionType, lockKey) + } + + private data class FallbackTarget( + val sectionType: RecommendedSectionType, + val lockKey: String + ) + + companion object { + const val AI_CHARACTER_LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER" + const val CHEER_CREATOR_LOCK_KEY = "lock:recommendation-snapshot-refresh:CHEER_CREATOR" + private const val LOCK_WAIT_MILLIS = 300L + private const val HOME_WAIT_MILLIS = 1_500L + private const val DEFAULT_WORKER_THREADS = 2 + } +} + +interface CheerCreatorSnapshotFallbackPort { + fun refreshCheerCreatorIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + ): List +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt deleted file mode 100644 index 6b860320..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt +++ /dev/null @@ -1,351 +0,0 @@ -package kr.co.vividnext.sodalive.v2.recommendation.application - -import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.Mockito -import org.redisson.api.RLock -import org.redisson.api.RedissonClient -import org.springframework.boot.test.system.CapturedOutput -import org.springframework.boot.test.system.OutputCaptureExtension -import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.support.AbstractPlatformTransactionManager -import org.springframework.transaction.support.DefaultTransactionStatus -import java.time.LocalDateTime -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executor -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit - -@ExtendWith(OutputCaptureExtension::class) -class AiCharacterSnapshotFallbackServiceTest { - @Test - @DisplayName("AI fallback은 최신 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다") - fun shouldReturnExistingSnapshotsWithoutLockOrRefresh() { - val snapshotPort = FakeFallbackSnapshotPort() - val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) - snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, listOf(snapshot(10L))) - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20) - - assertEquals(listOf(10L), snapshots.map { it.targetId }) - Mockito.verifyNoInteractions(redissonClient) - Mockito.verifyNoInteractions(refreshService) - } - - @Test - @DisplayName("AI fallback은 worker thread 안에서 section lock을 잡고 공통 AI refresh 후 최신 스냅샷을 다시 조회한다") - fun shouldRefreshMissingAiSnapshotsWithSectionLock() { - val snapshotPort = FakeFallbackSnapshotPort() - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val lock = Mockito.mock(RLock::class.java) - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) - Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) - Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) - val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) - Mockito.doAnswer { - snapshotPort.replaceSnapshots( - RecommendedSectionType.AI_CHARACTER, - LocalDateTime.of(2026, 7, 9, 14, 59, 59), - listOf(snapshot(1L)) - ) - null - }.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc) - val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc) - - assertEquals(listOf(1L), snapshots.map { it.targetId }) - Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY) - Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS) - Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc) - Mockito.verify(lock).unlock() - } - - @Test - @DisplayName("AI fallback의 snapshot 조회는 호출자 read transaction과 분리된 새 read transaction에서 실행한다") - fun shouldReadSnapshotsInRequiresNewReadOnlyTransactions() { - val snapshotPort = FakeFallbackSnapshotPort() - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val lock = Mockito.mock(RLock::class.java) - val transactionManager = RecordingTransactionManager() - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) - Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) - Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) - val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) - Mockito.doAnswer { - snapshotPort.replaceSnapshots( - RecommendedSectionType.AI_CHARACTER, - LocalDateTime.of(2026, 7, 9, 14, 59, 59), - listOf(snapshot(1L)) - ) - null - }.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc) - val service = AiCharacterSnapshotFallbackService( - snapshotPort = snapshotPort, - refreshService = refreshService, - redissonClient = redissonClient, - executor = directExecutor(), - transactionManager = transactionManager - ) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc) - - assertEquals(listOf(1L), snapshots.map { it.targetId }) - assertEquals( - listOf( - TransactionDefinition.PROPAGATION_REQUIRES_NEW, - TransactionDefinition.PROPAGATION_REQUIRES_NEW, - TransactionDefinition.PROPAGATION_REQUIRES_NEW, - TransactionDefinition.PROPAGATION_REQUIRES_NEW - ), - transactionManager.propagationBehaviors - ) - assertEquals(listOf(true, true, true, true), transactionManager.readOnlyFlags) - } - - @Test - @DisplayName("AI fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다") - fun shouldSkipRefreshWhenSectionLockIsNotAcquired() { - val snapshotPort = FakeFallbackSnapshotPort() - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val lock = Mockito.mock(RLock::class.java) - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) - Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false) - Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) - val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20) - - assertEquals(emptyList(), snapshots) - Mockito.verifyNoInteractions(refreshService) - Mockito.verify(lock, Mockito.never()).unlock() - } - - @Test - @DisplayName("AI fallback은 빈 스냅샷 refresh 완료 marker가 있으면 refresh를 반복하지 않는다") - fun shouldSkipRefreshWhenEmptySnapshotMarkerExists() { - val snapshotPort = FakeFallbackSnapshotPort() - val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) - snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, emptyList()) - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20) - - assertEquals(emptyList(), snapshots) - Mockito.verifyNoInteractions(redissonClient) - Mockito.verifyNoInteractions(refreshService) - } - - @Test - @DisplayName("AI fallback은 홈 대기 timeout 시 현재 요청은 빈 배열을 반환하고 worker는 취소하지 않아 다음 요청이 저장 결과를 읽는다") - fun shouldReturnEmptyOnTimeoutAndKeepWorkerRunning() { - val snapshotPort = FakeFallbackSnapshotPort() - val refreshStarted = CountDownLatch(1) - val allowRefreshComplete = CountDownLatch(1) - val refreshService = BlockingRefreshService(snapshotPort, refreshStarted, allowRefreshComplete) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val lock = Mockito.mock(RLock::class.java) - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) - Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) - Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) - val executor = Executors.newSingleThreadExecutor() - val service = AiCharacterSnapshotFallbackService( - snapshotPort, - refreshService, - redissonClient, - executor, - homeWaitMillis = 50 - ) - - val first = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) - assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) - allowRefreshComplete.countDown() - executor.shutdown() - assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) - val second = service.refreshIfMissing(offset = 0, limit = 20) - - assertEquals(emptyList(), first) - assertEquals(listOf(99L), second.map { it.targetId }) - } - - @Test - @DisplayName("AI fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열과 실패 로그를 반환한다") - fun shouldReturnEmptyAndLogWhenRefreshFails(output: CapturedOutput) { - val snapshotPort = FakeFallbackSnapshotPort() - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val lock = Mockito.mock(RLock::class.java) - val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) - Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) - Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) - Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService).refreshAiCharacterSnapshots(nowUtc) - val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc) - - assertEquals(emptyList(), snapshots) - assertEquals(true, output.out.contains("event=ai_character_snapshot_fallback_refresh_failure")) - Mockito.verify(lock).unlock() - } - - @Test - @DisplayName("AI fallback은 최신 스냅샷이 pre-check에서 감지되면 lock과 refresh를 실행하지 않는다") - fun shouldSkipLockAndRefreshWhenLatestSnapshotAppearsBeforeWorkerStarts() { - val snapshotPort = FakeFallbackSnapshotPort().apply { - replaceOnSecondRead = listOf(snapshot(77L)) - } - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20) - - assertEquals(listOf(77L), snapshots.map { it.targetId }) - Mockito.verifyNoInteractions(redissonClient) - Mockito.verifyNoInteractions(refreshService) - } - - @Test - @DisplayName("AI fallback은 lock 획득 후 최신 스냅샷을 재확인해 이미 생성됐으면 중복 refresh하지 않는다") - fun shouldDoubleCheckSnapshotsAfterLockBeforeRefresh() { - val snapshotPort = FakeFallbackSnapshotPort().apply { - replaceOnThirdRead = listOf(snapshot(88L)) - } - val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) - val redissonClient = Mockito.mock(RedissonClient::class.java) - val lock = Mockito.mock(RLock::class.java) - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) - Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) - Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) - val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) - - val snapshots = service.refreshIfMissing(offset = 0, limit = 20) - - assertEquals(listOf(88L), snapshots.map { it.targetId }) - Mockito.verifyNoInteractions(refreshService) - Mockito.verify(lock).unlock() - } - - private fun directExecutor(): Executor = Executor { command -> command.run() } -} - -private class FakeFallbackSnapshotPort : RecommendationSnapshotPort { - private val snapshots = mutableListOf() - var replaceOnSecondRead: List? = null - var replaceOnThirdRead: List? = null - private var readCount = 0 - - override fun findLatestSnapshots( - sectionType: RecommendedSectionType, - offset: Long, - limit: Int - ): List { - advanceReadScenario() - val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt } - return snapshots - .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L } - .drop(offset.toInt()) - .take(limit) - } - - override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { - advanceReadScenario() - return snapshots.any { it.sectionType == sectionType } - } - - private fun advanceReadScenario() { - readCount += 1 - if (readCount == 2) { - replaceOnSecondRead?.let { - replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it) - replaceOnSecondRead = null - } - } - if (readCount == 3) { - replaceOnThirdRead?.let { - replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it) - replaceOnThirdRead = null - } - } - } - - override fun replaceSnapshots( - sectionType: RecommendedSectionType, - snapshotAt: LocalDateTime, - newSnapshots: List - ) { - if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) { - snapshots.removeIf { it.sectionType == sectionType } - snapshots.add(snapshot(0L, snapshotAt)) - return - } - - snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } - snapshots.addAll(newSnapshots) - } -} - -private class BlockingRefreshService( - private val snapshotPort: RecommendationSnapshotPort, - private val refreshStarted: CountDownLatch, - private val allowRefreshComplete: CountDownLatch -) : RecommendationSnapshotRefreshService( - snapshotPort, - Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) -) { - override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int { - refreshStarted.countDown() - allowRefreshComplete.await(1, TimeUnit.SECONDS) - snapshotPort.replaceSnapshots( - RecommendedSectionType.AI_CHARACTER, - LocalDateTime.of(2026, 7, 9, 14, 59, 59), - listOf(snapshot(99L)) - ) - return 1 - } -} - -private fun snapshot( - targetId: Long, - snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59) -): RecommendationSnapshotRecord { - return RecommendationSnapshotRecord( - sectionType = RecommendedSectionType.AI_CHARACTER, - targetId = targetId, - score = 1.0, - snapshotAt = snapshotAt, - randomTieBreaker = 1.0 - ) -} - -private class RecordingTransactionManager : AbstractPlatformTransactionManager() { - val propagationBehaviors = mutableListOf() - val readOnlyFlags = mutableListOf() - - override fun doGetTransaction(): Any = Any() - - override fun doBegin(transaction: Any, definition: TransactionDefinition) { - propagationBehaviors.add(definition.propagationBehavior) - readOnlyFlags.add(definition.isReadOnly) - } - - override fun doCommit(status: DefaultTransactionStatus) { - } - - override fun doRollback(status: DefaultTransactionStatus) { - } -} 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 new file mode 100644 index 00000000..20c635ec --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt @@ -0,0 +1,390 @@ +package kr.co.vividnext.sodalive.v2.recommendation.application + +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.redisson.api.RLock +import org.redisson.api.RedissonClient +import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class RecommendationSnapshotFallbackServiceTest { + @Test + @DisplayName("AI fallback은 기대 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다") + fun shouldReturnExistingAiSnapshotsWithoutLockOrRefresh() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + snapshotAt, + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, snapshotAt)) + ) + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) + + assertEquals(listOf(10L), snapshots.map { it.targetId }) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + + @Test + @DisplayName("AI fallback은 section lock을 잡고 공통 refresh 후 기대 스냅샷을 다시 조회한다") + fun shouldRefreshMissingAiSnapshotsWithSectionLock() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + Mockito.doAnswer { + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L)) + ) + 1 + }.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc) + + assertEquals(listOf(1L), snapshots.map { it.targetId }) + Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY) + Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS) + Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("응원 크리에이터 fallback은 section lock을 잡고 공통 refresh 후 최신 스냅샷을 다시 조회한다") + fun shouldRefreshMissingCheerCreatorSnapshotsWithSectionLock() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + 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) + Mockito.doAnswer { + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L)) + ) + 1 + }.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + + assertEquals(listOf(1L), snapshots.map { it.targetId }) + Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY) + Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS) + Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다") + fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + staleSnapshotAt, + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, staleSnapshotAt)) + ) + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + 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) + Mockito.doAnswer { + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L)) + ) + 1 + }.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + + assertEquals(listOf(1L), snapshots.map { it.targetId }) + Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc) + } + + @Test + @DisplayName("응원 크리에이터 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다") + fun shouldSkipCheerCreatorRefreshWhenEmptySnapshotMarkerExists() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + emptyList() + ) + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + + assertEquals(emptyList(), snapshots) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + + @Test + @DisplayName("응원 크리에이터 fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다") + fun shouldSkipCheerCreatorRefreshWhenSectionLockIsNotAcquired() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + + assertEquals(emptyList(), snapshots) + Mockito.verifyNoInteractions(refreshService) + Mockito.verify(lock, Mockito.never()).unlock() + } + + @Test + @DisplayName("응원 크리에이터 fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열을 반환한다") + fun shouldReturnEmptyWhenCheerCreatorRefreshFails() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + 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) + Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + + assertEquals(emptyList(), snapshots) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("응원 크리에이터 fallback은 timeout 시 빈 배열을 반환하고 worker를 취소하지 않는다") + fun shouldReturnEmptyOnCheerCreatorTimeoutAndKeepWorkerRunning() { + 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 executor = Executors.newFixedThreadPool(2) + val service = RecommendationSnapshotFallbackService( + snapshotPort, + refreshService, + redissonClient, + executor, + 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() + assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) + val second = service.refreshCheerCreatorIfMissing( + offset = 0, + limit = 16, + nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + ) + + assertEquals(emptyList(), first) + assertEquals(listOf(99L), second.map { it.targetId }) + } + + @Test + @DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다") + fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val aiRefreshStarted = CountDownLatch(1) + val allowAiRefreshComplete = CountDownLatch(1) + val refreshService = BlockingAiAndFastCheerRefreshService(snapshotPort, aiRefreshStarted, allowAiRefreshComplete) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val aiLock = Mockito.mock(RLock::class.java) + val cheerLock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock) + Mockito.`when`(aiLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(true) + Mockito.`when`(cheerLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true) + val service = RecommendationSnapshotFallbackService( + snapshotPort, + refreshService, + redissonClient, + homeWaitMillis = 1_000 + ) + val aiExecutor = Executors.newSingleThreadExecutor() + + val aiFuture = aiExecutor.submit> { + service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) + } + assertEquals(true, aiRefreshStarted.await(1, TimeUnit.SECONDS)) + + val cheerSnapshots = service.refreshCheerCreatorIfMissing( + offset = 0, + limit = 16, + nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + ) + + allowAiRefreshComplete.countDown() + aiExecutor.shutdown() + assertEquals(true, aiExecutor.awaitTermination(1, TimeUnit.SECONDS)) + aiFuture.get(1, TimeUnit.SECONDS) + assertEquals(listOf(77L), cheerSnapshots.map { it.targetId }) + } + + private fun directExecutor(): Executor = Executor { command -> command.run() } +} + +private class BlockingCheerRefreshService( + private val snapshotPort: RecommendationSnapshotPort, + private val refreshStarted: CountDownLatch, + private val allowRefreshComplete: CountDownLatch +) : RecommendationSnapshotRefreshService( + snapshotPort, + Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) +) { + override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { + 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 BlockingAiAndFastCheerRefreshService( + private val snapshotPort: RecommendationSnapshotPort, + private val aiRefreshStarted: CountDownLatch, + private val allowAiRefreshComplete: CountDownLatch +) : RecommendationSnapshotRefreshService( + snapshotPort, + Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) +) { + override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int { + aiRefreshStarted.countDown() + allowAiRefreshComplete.await(1, TimeUnit.SECONDS) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 55L)) + ) + return 1 + } + + override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 77L)) + ) + return 1 + } +} + +private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort { + private val snapshots = mutableListOf() + + override fun findLatestSnapshots( + sectionType: RecommendedSectionType, + offset: Long, + limit: Int + ): List { + val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt } + return snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L } + .drop(offset.toInt()) + .take(limit) + } + + override fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long, + limit: Int + ): List { + return snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L } + .drop(offset.toInt()) + .take(limit) + } + + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { + return snapshots.any { it.sectionType == sectionType } + } + + override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { + return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + } + + override fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) { + if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) { + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + snapshots.add(snapshot(sectionType, 0L, snapshotAt)) + return + } + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + snapshots.addAll(newSnapshots) + } + + private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean { + return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR + } +} + +private fun snapshot( + sectionType: RecommendedSectionType, + targetId: Long, + snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59) +): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = targetId, + score = 1.0, + snapshotAt = snapshotAt, + randomTieBreaker = 1.0 + ) +}