feat(ranking): cold-start 스냅샷 생성을 위임한다
This commit is contained in:
@@ -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<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
|
||||
return groupBy { it.finalScore }
|
||||
.toSortedMap(compareByDescending { it })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user