diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt index 84c59b9a..d2a06adf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt @@ -20,6 +20,7 @@ class CreatorRankingQueryService( private val snapshotPort: CreatorRankingSnapshotPort, private val blockPort: CreatorRankingBlockPort, private val aggregationPort: CreatorRankingAggregationPort, + private val snapshotJobService: CreatorRankingSnapshotJobService, private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -36,6 +37,9 @@ class CreatorRankingQueryService( if (latestItems.isEmpty()) { if (snapshotPort.isSnapshotTableEmpty()) { val fallbackItems = aggregateColdStartFallback().toRankedItems() + if (fallbackItems.isNotEmpty()) { + delegateColdStartSnapshotRefresh() + } val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = fallbackItems) return@runCatching QueryLogResult( result = CreatorRankingResult( @@ -127,6 +131,18 @@ class CreatorRankingQueryService( }.getOrThrow() } + private fun delegateColdStartSnapshotRefresh() { + runCatching { + snapshotJobService.ensureLastCompletedWeekSnapshotForColdStart() + }.onFailure { ex -> + log.warn( + "event=creator_ranking_query_cold_start_snapshot_refresh_failure error={}", + ex.message, + ex + ) + } + } + private fun List.toRankedItems(): List { return groupBy { it.finalScore } .toSortedMap(compareByDescending { it }) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt index d503d30e..3de29235 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt @@ -1,31 +1,49 @@ package kr.co.vividnext.sodalive.v2.ranking.application import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger +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.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit @Service @Transactional(readOnly = true) class CreatorRankingSnapshotJobService( private val refreshService: CreatorRankingSnapshotRefreshService, private val jobPort: CreatorRankingSnapshotJobPort, + private val redissonClient: RedissonClient, + transactionManager: PlatformTransactionManager, private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() } ) { private val log = LoggerFactory.getLogger(javaClass) private val periodPolicy = CreatorRankingPeriodPolicy() + private val transactionTemplate = TransactionTemplate(transactionManager).also { template -> + template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } - @Transactional fun refreshLastCompletedWeekByScheduledJob() { - val now = nowProvider() - val period = periodPolicy.resolveLastCompletedWeek(now) - val utcRange = periodPolicy.toUtcRange(period) + withLastCompletedWeekPeriodLock { now, utcRange -> + transactionTemplate.executeWithoutResult { + refreshLastCompletedWeekByScheduledJob(now, utcRange) + } + } + } + + private fun refreshLastCompletedWeekByScheduledJob( + now: ZonedDateTime, + utcRange: CreatorRankingUtcRange + ) { val job = jobPort.save( CreatorRankingSnapshotJobRecord( aggregationStartAtUtc = utcRange.startInclusiveUtc, @@ -89,6 +107,32 @@ class CreatorRankingSnapshotJobService( jobPort.markPending(jobId) } + fun ensureLastCompletedWeekSnapshotForColdStart() { + withLastCompletedWeekPeriodLock { now, _ -> + transactionTemplate.executeWithoutResult { + refreshService.refreshLastCompletedWeek(now) + } + } + } + + private fun withLastCompletedWeekPeriodLock(action: (ZonedDateTime, CreatorRankingUtcRange) -> Unit) { + val now = nowProvider() + val period = periodPolicy.resolveLastCompletedWeek(now) + val utcRange = periodPolicy.toUtcRange(period) + val lockName = "lock:creator-ranking-snapshot-refresh:${utcRange.startInclusiveUtc}:${utcRange.endExclusiveUtc}" + val lock = redissonClient.getLock(lockName) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + action(now, utcRange) + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + private fun logJobStatusChanged( job: CreatorRankingSnapshotJobRecord, status: CreatorRankingSnapshotJobStatus, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt index 703f3d30..61dceb60 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Assertions.assertTrue 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.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.OutputCaptureExtension import java.time.LocalDateTime @@ -95,12 +96,17 @@ class CreatorRankingQueryServiceTest { fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() { val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val aggregationPort = FakeCreatorRankingQueryAggregationPort() + val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) snapshotPort.snapshotTableEmpty = true aggregationPort.candidates = listOf( candidate(creatorId = 1L, liveCanAmount = 100), candidate(creatorId = 2L, liveCanAmount = 200) ) - val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort) + val service = service( + snapshotPort = snapshotPort, + aggregationPort = aggregationPort, + snapshotJobService = snapshotJobService + ) val result = service.getCreatorRankings(viewerMemberId = null) @@ -112,6 +118,27 @@ class CreatorRankingQueryServiceTest { assertEquals(1, aggregationPort.aggregateCallCount) assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc) assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc) + Mockito.verify(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart() + } + + @Test + @DisplayName("cold-start fallback 후보가 없으면 스냅샷 생성 위임을 호출하지 않는다") + fun shouldNotDelegateColdStartSnapshotRefreshWhenFallbackIsEmpty() { + val snapshotPort = FakeCreatorRankingQuerySnapshotPort() + val aggregationPort = FakeCreatorRankingQueryAggregationPort() + val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) + snapshotPort.snapshotTableEmpty = true + val service = service( + snapshotPort = snapshotPort, + aggregationPort = aggregationPort, + snapshotJobService = snapshotJobService + ) + + val result = service.getCreatorRankings(viewerMemberId = null) + + assertFalse(result.showRankChange) + assertTrue(result.items.isEmpty()) + Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart() } @Test @@ -119,15 +146,21 @@ class CreatorRankingQueryServiceTest { fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() { val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val aggregationPort = FakeCreatorRankingQueryAggregationPort() + val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) snapshotPort.snapshotTableEmpty = false aggregationPort.candidates = listOf(candidate(creatorId = 1L)) - val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort) + val service = service( + snapshotPort = snapshotPort, + aggregationPort = aggregationPort, + snapshotJobService = snapshotJobService + ) val result = service.getCreatorRankings(viewerMemberId = null) assertFalse(result.showRankChange) assertTrue(result.items.isEmpty()) assertEquals(0, aggregationPort.aggregateCallCount) + Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart() } @Test @@ -339,15 +372,41 @@ class CreatorRankingQueryServiceTest { assertTrue(output.out.contains("error=fallback failed")) } + @Test + @DisplayName("cold-start 스냅샷 생성 위임 실패는 fallback 응답을 깨지 않고 로그로 남긴다") + fun shouldKeepFallbackResponseWhenColdStartSnapshotRefreshDelegationFails(output: CapturedOutput) { + val snapshotPort = FakeCreatorRankingQuerySnapshotPort() + val aggregationPort = FakeCreatorRankingQueryAggregationPort() + val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) + snapshotPort.snapshotTableEmpty = true + aggregationPort.candidates = listOf(candidate(creatorId = 1L)) + Mockito.doThrow(IllegalStateException("cold-start refresh failed")) + .`when`(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart() + val service = service( + snapshotPort = snapshotPort, + aggregationPort = aggregationPort, + snapshotJobService = snapshotJobService + ) + + val result = service.getCreatorRankings(viewerMemberId = null) + + assertFalse(result.showRankChange) + assertEquals(listOf(1L), result.items.map { it.creatorId }) + assertTrue(output.out.contains("event=creator_ranking_query_cold_start_snapshot_refresh_failure")) + assertTrue(output.out.contains("error=cold-start refresh failed")) + } + private fun service( snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(), - aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort() + aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(), + snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) ): CreatorRankingQueryService { return CreatorRankingQueryService( snapshotPort = snapshotPort, blockPort = blockPort, aggregationPort = aggregationPort, + snapshotJobService = snapshotJobService, nowProvider = { ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) }, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt index 77942094..7575ca96 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt @@ -11,11 +11,17 @@ 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.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.SimpleTransactionStatus import java.time.LocalDateTime import java.time.ZoneId import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit @ExtendWith(OutputCaptureExtension::class) class CreatorRankingSnapshotJobServiceTest { @@ -25,7 +31,8 @@ class CreatorRankingSnapshotJobServiceTest { val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val jobPort = FakeCreatorRankingSnapshotJobPort() val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) - val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now } + val redissonClient = periodLockRedissonClient(lockAcquired = true) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } service.refreshLastCompletedWeekByScheduledJob() @@ -44,7 +51,8 @@ class CreatorRankingSnapshotJobServiceTest { val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val jobPort = FakeCreatorRankingSnapshotJobPort() val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) - val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now } + val redissonClient = periodLockRedissonClient(lockAcquired = true) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } Mockito.doThrow(IllegalStateException("aggregate failed")) .`when`(refreshService).refreshLastCompletedWeek(now) @@ -62,7 +70,7 @@ class CreatorRankingSnapshotJobServiceTest { fun shouldCreateManualPendingJobForRequestedPeriod() { val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val jobPort = FakeCreatorRankingSnapshotJobPort() - val service = CreatorRankingSnapshotJobService(refreshService, jobPort) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager()) val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) @@ -82,7 +90,7 @@ class CreatorRankingSnapshotJobServiceTest { fun shouldFindJobsByRequestedPeriodAndStatuses() { val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val jobPort = FakeCreatorRankingSnapshotJobPort() - val service = CreatorRankingSnapshotJobService(refreshService, jobPort) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager()) val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) val failed = jobPort.save( @@ -122,7 +130,7 @@ class CreatorRankingSnapshotJobServiceTest { fun shouldRetryOnlyFailedSnapshotJob() { val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val jobPort = FakeCreatorRankingSnapshotJobPort() - val service = CreatorRankingSnapshotJobService(refreshService, jobPort) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager()) val failed = jobPort.save( CreatorRankingSnapshotJobRecord( aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), @@ -166,7 +174,8 @@ class CreatorRankingSnapshotJobServiceTest { val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val jobPort = FakeCreatorRankingSnapshotJobPort() val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) - val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now } + val redissonClient = periodLockRedissonClient(lockAcquired = true) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } service.refreshLastCompletedWeekByScheduledJob() @@ -183,7 +192,8 @@ class CreatorRankingSnapshotJobServiceTest { val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val jobPort = FakeCreatorRankingSnapshotJobPort() val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) - val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now } + val redissonClient = periodLockRedissonClient(lockAcquired = true) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } Mockito.doThrow(IllegalStateException("aggregate failed")) .`when`(refreshService).refreshLastCompletedWeek(now) @@ -197,6 +207,131 @@ class CreatorRankingSnapshotJobServiceTest { assertTrue(output.out.contains("status=FAILED")) assertTrue(output.out.contains("error=aggregate failed")) } + + @Test + @DisplayName("스케줄 job refresh는 cold-start와 같은 기간 기반 lock 경계를 사용한다") + fun shouldUseSamePeriodLockForScheduledJobRefresh() { + val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) + val jobPort = FakeCreatorRankingSnapshotJobPort() + val redissonClient = periodLockRedissonClient(lockAcquired = true) + val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) + val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00" + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } + + service.refreshLastCompletedWeekByScheduledJob() + + Mockito.verify(redissonClient).getLock(lockName) + Mockito.verify(refreshService).refreshLastCompletedWeek(now) + assertEquals(CreatorRankingSnapshotJobStatus.DONE, jobPort.jobs.single().status) + } + + @Test + @DisplayName("스케줄 job refresh는 기간 기반 lock 획득 실패 시 job 생성과 refresh를 건너뛴다") + fun shouldSkipScheduledJobRefreshWhenPeriodLockNotAcquired() { + val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) + val jobPort = FakeCreatorRankingSnapshotJobPort() + val redissonClient = periodLockRedissonClient(lockAcquired = false) + val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } + + service.refreshLastCompletedWeekByScheduledJob() + + assertTrue(jobPort.jobs.isEmpty()) + Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now) + } + + @Test + @DisplayName("기간 기반 lock은 스냅샷 refresh transaction commit 이후 해제한다") + fun shouldUnlockPeriodLockAfterRefreshTransactionCommit() { + val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) + val jobPort = FakeCreatorRankingSnapshotJobPort() + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val transactionManager = Mockito.mock(PlatformTransactionManager::class.java) + val transactionStatus = SimpleTransactionStatus() + val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) + val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00" + Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java))) + .thenReturn(transactionStatus) + val service = CreatorRankingSnapshotJobService( + refreshService, + jobPort, + redissonClient, + transactionManager + ) { now } + + service.refreshLastCompletedWeekByScheduledJob() + + val inOrder = Mockito.inOrder(transactionManager, lock) + inOrder.verify(transactionManager).commit(transactionStatus) + inOrder.verify(lock).unlock() + } + + @Test + @DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 시에만 refresh를 실행한다") + fun shouldRefreshColdStartSnapshotOnlyWhenPeriodLockAcquired() { + val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) + val jobPort = FakeCreatorRankingSnapshotJobPort() + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) + val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00" + Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } + + service.ensureLastCompletedWeekSnapshotForColdStart() + + Mockito.verify(redissonClient).getLock(lockName) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(refreshService).refreshLastCompletedWeek(now) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 실패 시 refresh를 실행하지 않는다") + fun shouldSkipColdStartSnapshotRefreshWhenPeriodLockNotAcquired() { + val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) + val jobPort = FakeCreatorRankingSnapshotJobPort() + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) + val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00" + Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now } + + service.ensureLastCompletedWeekSnapshotForColdStart() + + Mockito.verify(redissonClient).getLock(lockName) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now) + Mockito.verify(lock, Mockito.never()).unlock() + } +} + +private fun unusedRedissonClient(): RedissonClient = Mockito.mock(RedissonClient::class.java) + +private fun transactionManager(): PlatformTransactionManager { + val transactionManager = Mockito.mock(PlatformTransactionManager::class.java) + Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java))) + .thenReturn(SimpleTransactionStatus()) + return transactionManager +} + +private fun periodLockRedissonClient(lockAcquired: Boolean): RedissonClient { + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00" + Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(lockAcquired) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(lockAcquired) + return redissonClient } private class FakeCreatorRankingSnapshotJobPort : CreatorRankingSnapshotJobPort {