From 94b03a8c1121652279270bbdcbf4930158554b86 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 08:57:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=9D=B8=EA=B8=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecommendationSnapshotFallbackService.kt | 25 ++++++- .../RecommendationSnapshotRefreshService.kt | 59 ++++++++++----- ...commendationSnapshotFallbackServiceTest.kt | 55 +++++++++++++- ...ecommendationSnapshotRefreshServiceTest.kt | 73 +++++++++++++------ 4 files changed, 171 insertions(+), 41 deletions(-) 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 index 760ee662..d1d91f32 100644 --- 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 @@ -32,7 +32,7 @@ class RecommendationSnapshotFallbackService( executor: Executor? = null, private val homeWaitMillis: Long = HOME_WAIT_MILLIS, transactionManager: PlatformTransactionManager? = null -) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort { +) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort, PopularCommunitySnapshotFallbackPort { private val log = LoggerFactory.getLogger(javaClass) private val ownedExecutor: ExecutorService? = if (executor == null) { Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS) @@ -65,6 +65,19 @@ class RecommendationSnapshotFallbackService( return refreshIfMissing(target(RecommendedSectionType.CHEER_CREATOR, CHEER_CREATOR_LOCK_KEY), offset, limit, nowUtc) } + override fun refreshPopularCommunityIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + return refreshIfMissing( + target(RecommendedSectionType.POPULAR_COMMUNITY, POPULAR_COMMUNITY_LOCK_KEY), + offset, + limit, + nowUtc + ) + } + private fun refreshIfMissing( target: FallbackTarget, offset: Long, @@ -186,6 +199,7 @@ class RecommendationSnapshotFallbackService( return when (sectionType) { RecommendedSectionType.AI_CHARACTER -> refreshService.refreshAiCharacterSnapshots(nowUtc) RecommendedSectionType.CHEER_CREATOR -> refreshService.refreshCheerCreatorSnapshots(nowUtc) + RecommendedSectionType.POPULAR_COMMUNITY -> refreshService.refreshPopularCommunitySnapshots(nowUtc) else -> error("Unsupported fallback sectionType: $sectionType") } } @@ -207,6 +221,7 @@ class RecommendationSnapshotFallbackService( 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" + const val POPULAR_COMMUNITY_LOCK_KEY = "lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY" private const val LOCK_WAIT_MILLIS = 300L private const val HOME_WAIT_MILLIS = 1_500L private const val DEFAULT_WORKER_THREADS = 2 @@ -220,3 +235,11 @@ interface CheerCreatorSnapshotFallbackPort { nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ): List } + +interface PopularCommunitySnapshotFallbackPort { + fun refreshPopularCommunityIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + ): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt index 66c39c0f..963286b3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt @@ -12,7 +12,6 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime -import java.time.ZoneId import java.time.ZoneOffset import java.util.concurrent.TimeUnit @@ -38,18 +37,12 @@ open class RecommendationSnapshotRefreshService( @Transactional fun refreshDailySnapshots(now: LocalDateTime) { val startedAt = System.currentTimeMillis() - val snapshotAt = now - .atZone(UTC_ZONE) - .withZoneSameInstant(KST_ZONE) - .toLocalDate() - .minusDays(1) - .atTime(23, 59, 59) - val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay() + val snapshotAt = windowPolicy.previousKstDayUtcWindow(now).snapshotAt runCatching { val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now) val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now) - val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt) + val popularCommunityCount = refreshPopularCommunitySnapshotsWithSectionLock(now) RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount) }.onSuccess { counts -> afterCommit { @@ -119,6 +112,30 @@ open class RecommendationSnapshotRefreshService( 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 { val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc) val lock = client.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY) @@ -151,6 +168,22 @@ open class RecommendationSnapshotRefreshService( 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) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { 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( val aiCharacterCount: Int, val cheerCreatorCount: Int, @@ -198,7 +225,5 @@ open class RecommendationSnapshotRefreshService( private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20 private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16 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") } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt index 20c635ec..925ae2e2 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt @@ -97,6 +97,57 @@ class RecommendationSnapshotFallbackServiceTest { 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(), snapshots) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + @Test @DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다") fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() { @@ -371,7 +422,9 @@ private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPor } 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 } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt index 63d8acdf..471bc131 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt @@ -53,8 +53,9 @@ class RecommendationSnapshotRefreshServiceTest { val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val service = service(snapshotPort = snapshotPort, queryPort = queryPort) 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, 23, 0, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) + 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 aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) 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( RecommendationSnapshotRecord( sectionType = RecommendedSectionType.POPULAR_COMMUNITY, @@ -112,7 +113,7 @@ class RecommendationSnapshotRefreshServiceTest { Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20) 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("aiCharacterCount=1")) } @@ -123,8 +124,8 @@ class RecommendationSnapshotRefreshServiceTest { val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val service = service(queryPort = queryPort) 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, 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`( queryPort.findAiCharacterSnapshots( LocalDateTime.of(2026, 5, 28, 15, 0, 0), @@ -139,7 +140,7 @@ class RecommendationSnapshotRefreshServiceTest { 16 ) ).thenReturn(emptyList()) - Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) + Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList()) TransactionSynchronizationManager.initSynchronization() try { @@ -161,8 +162,9 @@ class RecommendationSnapshotRefreshServiceTest { val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val service = service(snapshotPort = snapshotPort, queryPort = queryPort) 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, 23, 0, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) + 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 aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) @@ -184,7 +186,7 @@ class RecommendationSnapshotRefreshServiceTest { ).thenReturn( 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)) ) @@ -203,16 +205,21 @@ class RecommendationSnapshotRefreshServiceTest { val redissonClient = Mockito.mock(RedissonClient::class.java) val lock = 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 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, 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.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.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).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.findAiCharacterSnapshots( LocalDateTime.of(2026, 5, 28, 15, 0, 0), @@ -227,7 +234,7 @@ class RecommendationSnapshotRefreshServiceTest { 16 ) ).thenReturn(emptyList()) - Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) + Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList()) service.refreshDailySnapshots(now) @@ -240,6 +247,7 @@ class RecommendationSnapshotRefreshServiceTest { ) Mockito.verify(lock).unlock() Mockito.verify(cheerLock).unlock() + Mockito.verify(popularLock).unlock() } @Test @@ -250,22 +258,28 @@ class RecommendationSnapshotRefreshServiceTest { val redissonClient = Mockito.mock(RedissonClient::class.java) val aiLock = 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 now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) - val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) - val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0) + 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 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.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.isHeldByCurrentThread).thenReturn(false) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).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( 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) @@ -274,6 +288,7 @@ class RecommendationSnapshotRefreshServiceTest { Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16) assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) Mockito.verify(cheerLock).unlock() + Mockito.verify(popularLock).unlock() } @Test @@ -284,17 +299,23 @@ class RecommendationSnapshotRefreshServiceTest { val redissonClient = Mockito.mock(RedissonClient::class.java) val aiLock = 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 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, 23, 0, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) + 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.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.isHeldByCurrentThread).thenReturn(false) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).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)) ) @@ -311,6 +332,7 @@ class RecommendationSnapshotRefreshServiceTest { ) assertEquals(listOf(20L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) Mockito.verify(cheerLock, Mockito.never()).unlock() + Mockito.verify(popularLock).unlock() } @Test @@ -321,16 +343,22 @@ class RecommendationSnapshotRefreshServiceTest { val redissonClient = Mockito.mock(RedissonClient::class.java) val lock = 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 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, 23, 0, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) + 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.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.isHeldByCurrentThread).thenReturn(false) Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).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( LocalDateTime.of(2026, 5, 28, 15, 0, 0), @@ -340,7 +368,7 @@ class RecommendationSnapshotRefreshServiceTest { ).thenReturn( 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) @@ -356,6 +384,7 @@ class RecommendationSnapshotRefreshServiceTest { assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) Mockito.verify(lock, Mockito.never()).unlock() Mockito.verify(cheerLock).unlock() + Mockito.verify(popularLock).unlock() } @Test