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 d42ca09d..66c39c0f 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 @@ -48,7 +48,7 @@ open class RecommendationSnapshotRefreshService( runCatching { val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now) - val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt) + val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now) val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt) RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount) }.onSuccess { counts -> @@ -95,14 +95,38 @@ open class RecommendationSnapshotRefreshService( return snapshots.size } + @Transactional + open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int { + val startedAt = System.currentTimeMillis() + val window = windowPolicy.previousKstDayUtcWindow(nowUtc) + val snapshots = queryPort.findCheerCreatorSnapshots( + window.startUtc, + window.endExclusiveUtc, + CHEER_CREATOR_SNAPSHOT_LIMIT + ) + snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots) + afterCommit { + log.info( + "event=cheer_creator_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(AiCharacterSnapshotFallbackService.LOCK_KEY) + val lock = client.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY) if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) { log.info( "event=ai_character_recommendation_snapshot_refresh_lock_missed lockKey={}", - AiCharacterSnapshotFallbackService.LOCK_KEY + RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY ) return 0 } @@ -111,6 +135,22 @@ open class RecommendationSnapshotRefreshService( return refreshAiCharacterSnapshots(nowUtc) } + private fun refreshCheerCreatorSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int { + val client = redissonClient ?: return refreshCheerCreatorSnapshots(nowUtc) + val lock = client.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY) + + if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) { + log.info( + "event=cheer_creator_recommendation_snapshot_refresh_lock_missed lockKey={}", + RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY + ) + return 0 + } + + unlockAfterTransaction(lock) + return refreshCheerCreatorSnapshots(nowUtc) + } + private fun unlockAfterTransaction(lock: org.redisson.api.RLock) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (lock.isHeldByCurrentThread) { @@ -130,12 +170,6 @@ open class RecommendationSnapshotRefreshService( ) } - private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int { - val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT) - snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots) - return snapshots.size - } - 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) 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 b34e3506..63d8acdf 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 @@ -70,13 +70,13 @@ class RecommendationSnapshotRefreshServiceTest { ) ) ) - Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( + Mockito.`when`(queryPort.findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)).thenReturn( listOf( RecommendationSnapshotRecord( sectionType = RecommendedSectionType.CHEER_CREATOR, targetId = 22L, score = 792.22, - snapshotAt = snapshotAt, + snapshotAt = aiSnapshotAt, randomTieBreaker = 0.2 ) ) @@ -111,7 +111,7 @@ class RecommendationSnapshotRefreshServiceTest { assertEquals(40.0, communitySnapshots.single().score, 0.0001) Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20) - Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16) + Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16) Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20) assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success")) assertEquals(true, output.out.contains("aiCharacterCount=1")) @@ -132,7 +132,13 @@ class RecommendationSnapshotRefreshServiceTest { 20 ) ).thenReturn(emptyList()) - Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList()) + Mockito.`when`( + queryPort.findCheerCreatorSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 16 + ) + ).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) TransactionSynchronizationManager.initSynchronization() @@ -169,7 +175,13 @@ class RecommendationSnapshotRefreshServiceTest { ).thenReturn( listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = aiSnapshotAt)) ) - Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( + Mockito.`when`( + queryPort.findCheerCreatorSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + aiWindowEndExclusive, + 16 + ) + ).thenReturn( listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt)) ) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( @@ -190,13 +202,17 @@ class RecommendationSnapshotRefreshServiceTest { val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val redissonClient = Mockito.mock(RedissonClient::class.java) val lock = Mockito.mock(RLock::class.java) + val cheerLock = 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) - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.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`(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`( queryPort.findAiCharacterSnapshots( LocalDateTime.of(2026, 5, 28, 15, 0, 0), @@ -204,12 +220,18 @@ class RecommendationSnapshotRefreshServiceTest { 20 ) ).thenReturn(emptyList()) - Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList()) + Mockito.`when`( + queryPort.findCheerCreatorSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 16 + ) + ).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) service.refreshDailySnapshots(now) - Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY) + Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY) Mockito.verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS) Mockito.verify(queryPort).findAiCharacterSnapshots( LocalDateTime.of(2026, 5, 28, 15, 0, 0), @@ -217,6 +239,78 @@ class RecommendationSnapshotRefreshServiceTest { 20 ) Mockito.verify(lock).unlock() + Mockito.verify(cheerLock).unlock() + } + + @Test + @DisplayName("일 스냅샷 갱신은 응원 크리에이터 섹션 lock을 획득한 경우에만 응원 refresh를 실행한다") + fun shouldRefreshCheerCreatorSectionOnlyWhenSectionLockAcquired() { + val snapshotPort = FakeRecommendationSnapshotPort() + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val aiLock = Mockito.mock(RLock::class.java) + val cheerLock = 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 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`(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`(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()) + + service.refreshDailySnapshots(now) + + Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY) + Mockito.verify(cheerLock).tryLock(0, -1, TimeUnit.MILLISECONDS) + Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16) + assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) + Mockito.verify(cheerLock).unlock() + } + + @Test + @DisplayName("일 스냅샷 갱신은 응원 크리에이터 섹션 lock 획득 실패 시 응원 refresh를 건너뛰고 다른 섹션은 유지한다") + fun shouldSkipOnlyCheerCreatorRefreshWhenSectionLockNotAcquired() { + val snapshotPort = FakeRecommendationSnapshotPort() + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val aiLock = Mockito.mock(RLock::class.java) + val cheerLock = 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) + 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(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( + listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 20L, score = 20.0, snapshotAt = snapshotAt)) + ) + + service.refreshDailySnapshots(now) + + Mockito.verify(queryPort, Mockito.never()).findCheerCreatorSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 16 + ) + assertEquals( + emptyList(), + snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR) + ) + assertEquals(listOf(20L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) + Mockito.verify(cheerLock, Mockito.never()).unlock() } @Test @@ -226,14 +320,24 @@ class RecommendationSnapshotRefreshServiceTest { val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val redissonClient = Mockito.mock(RedissonClient::class.java) val lock = Mockito.mock(RLock::class.java) + val cheerLock = 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) - Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.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`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false) Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) - Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( + Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true) + Mockito.`when`( + queryPort.findCheerCreatorSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 16 + ) + ).thenReturn( listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt)) ) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) @@ -251,6 +355,7 @@ class RecommendationSnapshotRefreshServiceTest { ) assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) Mockito.verify(lock, Mockito.never()).unlock() + Mockito.verify(cheerLock).unlock() } @Test @@ -360,17 +465,35 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort { return all.drop(offset.toInt()).take(limit) } + override fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long, + limit: Int + ): List { + val all = snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L } + .sortedWith(compareByDescending { it.score }.thenBy { it.randomTieBreaker }) + + if (offset == 0L && limit == Int.MAX_VALUE) return all + return all.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() && sectionType == RecommendedSectionType.AI_CHARACTER) { - snapshots.removeIf { it.sectionType == sectionType } + if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) { + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.add( RecommendationSnapshotRecord( sectionType = sectionType, @@ -386,4 +509,8 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort { 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 + } }