feat(home): 인기 커뮤니티 스냅샷 갱신 경로를 분리한다

This commit is contained in:
2026-07-10 08:57:12 +09:00
parent 6cda73270f
commit 94b03a8c11
4 changed files with 171 additions and 41 deletions

View File

@@ -32,7 +32,7 @@ class RecommendationSnapshotFallbackService(
executor: Executor? = null, executor: Executor? = null,
private val homeWaitMillis: Long = HOME_WAIT_MILLIS, private val homeWaitMillis: Long = HOME_WAIT_MILLIS,
transactionManager: PlatformTransactionManager? = null transactionManager: PlatformTransactionManager? = null
) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort { ) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort, PopularCommunitySnapshotFallbackPort {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val ownedExecutor: ExecutorService? = if (executor == null) { private val ownedExecutor: ExecutorService? = if (executor == null) {
Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS) Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS)
@@ -65,6 +65,19 @@ class RecommendationSnapshotFallbackService(
return refreshIfMissing(target(RecommendedSectionType.CHEER_CREATOR, CHEER_CREATOR_LOCK_KEY), offset, limit, nowUtc) return refreshIfMissing(target(RecommendedSectionType.CHEER_CREATOR, CHEER_CREATOR_LOCK_KEY), offset, limit, nowUtc)
} }
override fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
return refreshIfMissing(
target(RecommendedSectionType.POPULAR_COMMUNITY, POPULAR_COMMUNITY_LOCK_KEY),
offset,
limit,
nowUtc
)
}
private fun refreshIfMissing( private fun refreshIfMissing(
target: FallbackTarget, target: FallbackTarget,
offset: Long, offset: Long,
@@ -186,6 +199,7 @@ class RecommendationSnapshotFallbackService(
return when (sectionType) { return when (sectionType) {
RecommendedSectionType.AI_CHARACTER -> refreshService.refreshAiCharacterSnapshots(nowUtc) RecommendedSectionType.AI_CHARACTER -> refreshService.refreshAiCharacterSnapshots(nowUtc)
RecommendedSectionType.CHEER_CREATOR -> refreshService.refreshCheerCreatorSnapshots(nowUtc) RecommendedSectionType.CHEER_CREATOR -> refreshService.refreshCheerCreatorSnapshots(nowUtc)
RecommendedSectionType.POPULAR_COMMUNITY -> refreshService.refreshPopularCommunitySnapshots(nowUtc)
else -> error("Unsupported fallback sectionType: $sectionType") else -> error("Unsupported fallback sectionType: $sectionType")
} }
} }
@@ -207,6 +221,7 @@ class RecommendationSnapshotFallbackService(
companion object { companion object {
const val AI_CHARACTER_LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER" const val AI_CHARACTER_LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER"
const val CHEER_CREATOR_LOCK_KEY = "lock:recommendation-snapshot-refresh:CHEER_CREATOR" const val CHEER_CREATOR_LOCK_KEY = "lock:recommendation-snapshot-refresh:CHEER_CREATOR"
const val POPULAR_COMMUNITY_LOCK_KEY = "lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY"
private const val LOCK_WAIT_MILLIS = 300L private const val LOCK_WAIT_MILLIS = 300L
private const val HOME_WAIT_MILLIS = 1_500L private const val HOME_WAIT_MILLIS = 1_500L
private const val DEFAULT_WORKER_THREADS = 2 private const val DEFAULT_WORKER_THREADS = 2
@@ -220,3 +235,11 @@ interface CheerCreatorSnapshotFallbackPort {
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): List<RecommendationSnapshotRecord> ): List<RecommendationSnapshotRecord>
} }
interface PopularCommunitySnapshotFallbackPort {
fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): List<RecommendationSnapshotRecord>
}

View File

@@ -12,7 +12,6 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -38,18 +37,12 @@ open class RecommendationSnapshotRefreshService(
@Transactional @Transactional
fun refreshDailySnapshots(now: LocalDateTime) { fun refreshDailySnapshots(now: LocalDateTime) {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
val snapshotAt = now val snapshotAt = windowPolicy.previousKstDayUtcWindow(now).snapshotAt
.atZone(UTC_ZONE)
.withZoneSameInstant(KST_ZONE)
.toLocalDate()
.minusDays(1)
.atTime(23, 59, 59)
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
runCatching { runCatching {
val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now) val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now)
val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now) val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now)
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt) val popularCommunityCount = refreshPopularCommunitySnapshotsWithSectionLock(now)
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount) RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
}.onSuccess { counts -> }.onSuccess { counts ->
afterCommit { afterCommit {
@@ -119,6 +112,30 @@ open class RecommendationSnapshotRefreshService(
return snapshots.size return snapshots.size
} }
@Transactional
open fun refreshPopularCommunitySnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
val startedAt = System.currentTimeMillis()
val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
val snapshots = queryPort.findPopularCommunitySnapshots(
window.startUtc,
window.endExclusiveUtc,
POPULAR_COMMUNITY_SNAPSHOT_LIMIT
)
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, window.snapshotAt, snapshots)
afterCommit {
log.info(
"event=popular_community_recommendation_snapshot_refresh_success " +
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
window.snapshotAt,
window.startUtc,
window.endExclusiveUtc,
snapshots.size,
System.currentTimeMillis() - startedAt
)
}
return snapshots.size
}
private fun refreshAiCharacterSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int { private fun refreshAiCharacterSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc) val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc)
val lock = client.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY) val lock = client.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
@@ -151,6 +168,22 @@ open class RecommendationSnapshotRefreshService(
return refreshCheerCreatorSnapshots(nowUtc) return refreshCheerCreatorSnapshots(nowUtc)
} }
private fun refreshPopularCommunitySnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
val client = redissonClient ?: return refreshPopularCommunitySnapshots(nowUtc)
val lock = client.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info(
"event=popular_community_recommendation_snapshot_refresh_lock_missed lockKey={}",
RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY
)
return 0
}
unlockAfterTransaction(lock)
return refreshPopularCommunitySnapshots(nowUtc)
}
private fun unlockAfterTransaction(lock: org.redisson.api.RLock) { private fun unlockAfterTransaction(lock: org.redisson.api.RLock) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
if (lock.isHeldByCurrentThread) { if (lock.isHeldByCurrentThread) {
@@ -170,12 +203,6 @@ open class RecommendationSnapshotRefreshService(
) )
} }
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots)
return snapshots.size
}
private data class RefreshCounts( private data class RefreshCounts(
val aiCharacterCount: Int, val aiCharacterCount: Int,
val cheerCreatorCount: Int, val cheerCreatorCount: Int,
@@ -198,7 +225,5 @@ open class RecommendationSnapshotRefreshService(
private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20 private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20
private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16 private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16
private const val POPULAR_COMMUNITY_SNAPSHOT_LIMIT = 20 private const val POPULAR_COMMUNITY_SNAPSHOT_LIMIT = 20
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
} }
} }

View File

@@ -97,6 +97,57 @@ class RecommendationSnapshotFallbackServiceTest {
Mockito.verify(lock).unlock() Mockito.verify(lock).unlock()
} }
@Test
@DisplayName("인기 커뮤니티 fallback은 section lock을 잡고 공통 refresh 후 대상일 스냅샷을 다시 조회한다")
fun shouldRefreshMissingPopularCommunitySnapshotsWithSectionLock() {
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.POPULAR_COMMUNITY_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.POPULAR_COMMUNITY,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 7L))
)
1
}.`when`(refreshService).refreshPopularCommunitySnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshPopularCommunityIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(listOf(7L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshPopularCommunitySnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("인기 커뮤니티 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다")
fun shouldSkipPopularCommunityRefreshWhenEmptySnapshotMarkerExists() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
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.refreshPopularCommunityIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test @Test
@DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다") @DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다")
fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() { fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() {
@@ -371,7 +422,9 @@ private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPor
} }
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean { private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR return sectionType == RecommendedSectionType.AI_CHARACTER ||
sectionType == RecommendedSectionType.CHEER_CREATOR ||
sectionType == RecommendedSectionType.POPULAR_COMMUNITY
} }
} }

View File

@@ -53,8 +53,9 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort) val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val aiWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0) val aiWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
@@ -81,7 +82,7 @@ class RecommendationSnapshotRefreshServiceTest {
) )
) )
) )
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf( listOf(
RecommendationSnapshotRecord( RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY, sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
@@ -112,7 +113,7 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20) Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16) Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20) Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success")) assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aiCharacterCount=1")) assertEquals(true, output.out.contains("aiCharacterCount=1"))
} }
@@ -123,8 +124,8 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(queryPort = queryPort) val service = service(queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`( Mockito.`when`(
queryPort.findAiCharacterSnapshots( queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -139,7 +140,7 @@ class RecommendationSnapshotRefreshServiceTest {
16 16
) )
).thenReturn(emptyList()) ).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
TransactionSynchronizationManager.initSynchronization() TransactionSynchronizationManager.initSynchronization()
try { try {
@@ -161,8 +162,9 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort) val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
@@ -184,7 +186,7 @@ class RecommendationSnapshotRefreshServiceTest {
).thenReturn( ).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
) )
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt))
) )
@@ -203,16 +205,21 @@ class RecommendationSnapshotRefreshServiceTest {
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)
val cheerLock = Mockito.mock(RLock::class.java) val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient) val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`( Mockito.`when`(
queryPort.findAiCharacterSnapshots( queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -227,7 +234,7 @@ class RecommendationSnapshotRefreshServiceTest {
16 16
) )
).thenReturn(emptyList()) ).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now) service.refreshDailySnapshots(now)
@@ -240,6 +247,7 @@ class RecommendationSnapshotRefreshServiceTest {
) )
Mockito.verify(lock).unlock() Mockito.verify(lock).unlock()
Mockito.verify(cheerLock).unlock() Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
} }
@Test @Test
@@ -250,22 +258,28 @@ class RecommendationSnapshotRefreshServiceTest {
val redissonClient = Mockito.mock(RedissonClient::class.java) val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java) val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java) val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient) val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val cheerWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0) val cheerWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
val cheerWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val cheerWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false) Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false) Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)).thenReturn( Mockito.`when`(queryPort.findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 10L, score = 10.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 10L, score = 10.0, snapshotAt = snapshotAt))
) )
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now) service.refreshDailySnapshots(now)
@@ -274,6 +288,7 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16) Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)
assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
Mockito.verify(cheerLock).unlock() Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
} }
@Test @Test
@@ -284,17 +299,23 @@ class RecommendationSnapshotRefreshServiceTest {
val redissonClient = Mockito.mock(RedissonClient::class.java) val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java) val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java) val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient) val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false) Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false) Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(false) Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 20L, score = 20.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 20L, score = 20.0, snapshotAt = snapshotAt))
) )
@@ -311,6 +332,7 @@ class RecommendationSnapshotRefreshServiceTest {
) )
assertEquals(listOf(20L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) assertEquals(listOf(20L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
Mockito.verify(cheerLock, Mockito.never()).unlock() Mockito.verify(cheerLock, Mockito.never()).unlock()
Mockito.verify(popularLock).unlock()
} }
@Test @Test
@@ -321,16 +343,22 @@ class RecommendationSnapshotRefreshServiceTest {
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)
val cheerLock = Mockito.mock(RLock::class.java) val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient) val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false) Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`( Mockito.`when`(
queryPort.findCheerCreatorSnapshots( queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -340,7 +368,7 @@ class RecommendationSnapshotRefreshServiceTest {
).thenReturn( ).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt))
) )
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now) service.refreshDailySnapshots(now)
@@ -356,6 +384,7 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
Mockito.verify(lock, Mockito.never()).unlock() Mockito.verify(lock, Mockito.never()).unlock()
Mockito.verify(cheerLock).unlock() Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
} }
@Test @Test