feat(home): 크리에이터 랭킹 fallback refresh job을 실행한다

This commit is contained in:
2026-07-10 14:45:10 +09:00
parent 10020b03d6
commit d5ffa56f17
2 changed files with 134 additions and 402 deletions

View File

@@ -35,15 +35,26 @@ class CreatorRankingSnapshotJobService(
fun refreshLastCompletedWeekByScheduledJob() { fun refreshLastCompletedWeekByScheduledJob() {
withLastCompletedWeekPeriodLock { now, utcRange -> withLastCompletedWeekPeriodLock { now, utcRange ->
refreshLastCompletedWeekByScheduledJob(now, utcRange) refreshLastCompletedWeek(now, utcRange, CreatorRankingSnapshotJobTrigger.SCHEDULED)
} }
} }
private fun refreshLastCompletedWeekByScheduledJob( fun refreshLastCompletedWeekByFallback(): Boolean {
var refreshed = false
withLastCompletedWeekPeriodLock { now, utcRange ->
if (fallbackCountReachedLimit(utcRange)) return@withLastCompletedWeekPeriodLock
refreshLastCompletedWeek(now, utcRange, CreatorRankingSnapshotJobTrigger.FALLBACK)
refreshed = true
}
return refreshed
}
private fun refreshLastCompletedWeek(
now: ZonedDateTime, now: ZonedDateTime,
utcRange: CreatorRankingUtcRange utcRange: CreatorRankingUtcRange,
trigger: CreatorRankingSnapshotJobTrigger
) { ) {
val job = savePendingJob(utcRange, CreatorRankingSnapshotJobTrigger.SCHEDULED) val job = savePendingJob(utcRange, trigger)
val jobId = job.id ?: return val jobId = job.id ?: return
markProcessing(jobId) markProcessing(jobId)
logJobStatusChanged(job, CreatorRankingSnapshotJobStatus.PROCESSING) logJobStatusChanged(job, CreatorRankingSnapshotJobStatus.PROCESSING)
@@ -85,22 +96,25 @@ class CreatorRankingSnapshotJobService(
}!! }!!
} }
private fun markProcessing(jobId: Long) { private fun fallbackCountReachedLimit(utcRange: CreatorRankingUtcRange): Boolean {
transactionTemplate.executeWithoutResult { return jobPort.countByRankingTypeAndPeriodAndTrigger(
jobPort.markProcessing(jobId, LocalDateTime.now()) rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
trigger = CreatorRankingSnapshotJobTrigger.FALLBACK
) >= FALLBACK_LIMIT
} }
private fun markProcessing(jobId: Long) {
transactionTemplate.executeWithoutResult { jobPort.markProcessing(jobId, LocalDateTime.now()) }
} }
private fun markDone(jobId: Long) { private fun markDone(jobId: Long) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult { jobPort.markDone(jobId, LocalDateTime.now()) }
jobPort.markDone(jobId, LocalDateTime.now())
}
} }
private fun markFailed(jobId: Long, message: String?) { private fun markFailed(jobId: Long, message: String?) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult { jobPort.markFailed(jobId, LocalDateTime.now(), message) }
jobPort.markFailed(jobId, LocalDateTime.now(), message)
}
} }
@Transactional @Transactional
@@ -128,34 +142,22 @@ class CreatorRankingSnapshotJobService(
aggregationEndAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime,
statuses: List<CreatorRankingSnapshotJobStatus> = CreatorRankingSnapshotJobStatus.values().toList() statuses: List<CreatorRankingSnapshotJobStatus> = CreatorRankingSnapshotJobStatus.values().toList()
): List<CreatorRankingSnapshotJobRecord> { ): List<CreatorRankingSnapshotJobRecord> {
return jobPort.findByPeriodAndStatuses( return jobPort.findByPeriodAndStatuses(aggregationStartAtUtc, aggregationEndAtUtc, statuses)
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
statuses = statuses
)
} }
@Transactional @Transactional
fun retryFailedJob(jobId: Long) { fun retryFailedJob(jobId: Long) {
val job = jobPort.findById(jobId) ?: return val job = jobPort.findById(jobId) ?: return
if (job.status != CreatorRankingSnapshotJobStatus.FAILED) return if (job.status != CreatorRankingSnapshotJobStatus.FAILED) return
jobPort.markPending(jobId) jobPort.markPending(jobId)
} }
fun ensureLastCompletedWeekSnapshotForColdStart() {
withLastCompletedWeekPeriodLock { now, _ ->
transactionTemplate.executeWithoutResult {
refreshService.refreshLastCompletedWeek(now)
}
}
}
private fun withLastCompletedWeekPeriodLock(action: (ZonedDateTime, CreatorRankingUtcRange) -> Unit) { private fun withLastCompletedWeekPeriodLock(action: (ZonedDateTime, CreatorRankingUtcRange) -> Unit) {
val now = nowProvider() val now = nowProvider()
val period = periodPolicy.resolveLastCompletedWeek(now) val period = periodPolicy.resolveLastCompletedWeek(now)
val utcRange = periodPolicy.toUtcRange(period) val utcRange = periodPolicy.toUtcRange(period)
val lockName = "lock:creator-ranking-snapshot-refresh:${utcRange.startInclusiveUtc}:${utcRange.endExclusiveUtc}" val lockName = "lock:creator-ranking-snapshot-refresh:" +
"${CreatorRankingType.WEEKLY}:${utcRange.startInclusiveUtc}:${utcRange.endExclusiveUtc}"
val lock = redissonClient.getLock(lockName) val lock = redissonClient.getLock(lockName)
try { try {
@@ -185,4 +187,8 @@ class CreatorRankingSnapshotJobService(
error error
) )
} }
companion object {
private const val FALLBACK_LIMIT = 3L
}
} }

View File

@@ -6,458 +6,184 @@ import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRec
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito import org.mockito.Mockito
import org.redisson.api.RLock import org.redisson.api.RLock
import org.redisson.api.RedissonClient 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.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.SimpleTransactionStatus import org.springframework.transaction.support.SimpleTransactionStatus
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ExtendWith(OutputCaptureExtension::class)
class CreatorRankingSnapshotJobServiceTest { class CreatorRankingSnapshotJobServiceTest {
@Test @Test
@DisplayName("스케줄 실행은 집계 기간을 포함한 SCHEDULED job을 생성하고 성공 시 DONE으로 기록한다") @DisplayName("fallback은 FALLBACK job을 PENDING -> PROCESSING -> DONE으로 전이하고 refresh를 실행한다")
fun shouldCreateScheduledJobAndMarkDoneWhenRefreshSucceeds() { fun shouldRefreshByFallbackWithJobStatusTransitions() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) val service = service(refreshService, jobPort)
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.refreshLastCompletedWeekByScheduledJob() val refreshed = service.refreshLastCompletedWeekByFallback()
val job = jobPort.jobs.single() assertTrue(refreshed)
assertEquals(CreatorRankingType.WEEKLY, job.rankingType) assertEquals(CreatorRankingSnapshotJobTrigger.FALLBACK, jobPort.jobs.single().trigger)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), job.aggregationStartAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), job.aggregationEndAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), job.visibleFromAtUtc)
assertEquals(CreatorRankingSnapshotJobTrigger.SCHEDULED, job.trigger)
assertEquals(CreatorRankingSnapshotJobStatus.DONE, job.status)
assertEquals(null, job.lastError)
Mockito.verify(refreshService).refreshLastCompletedWeek(now)
}
@Test
@DisplayName("스케줄 실행 실패는 FAILED 상태와 실패 사유를 기록하고 예외를 전파한다")
fun shouldMarkScheduledJobFailedWhenRefreshFails() {
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 redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(now)
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob()
}
assertEquals("aggregate failed", exception.message)
assertEquals(CreatorRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status)
assertEquals("aggregate failed", jobPort.jobs.single().lastError)
}
@Test
@DisplayName("관리자 수동 생성은 지정 UTC 기간의 MANUAL PENDING job을 만든다")
fun shouldCreateManualPendingJobForRequestedPeriod() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
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 job = service.createManualJob(startAt, endAt)
assertEquals(startAt, job.aggregationStartAtUtc)
assertEquals(endAt, job.aggregationEndAtUtc)
assertEquals(CreatorRankingType.WEEKLY, job.rankingType)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), job.visibleFromAtUtc)
assertEquals(CreatorRankingSnapshotJobTrigger.MANUAL, job.trigger)
assertEquals(CreatorRankingSnapshotJobStatus.PENDING, job.status)
assertEquals(null, job.lastError)
assertEquals(null, job.processingStartedAt)
assertEquals(null, job.processedAt)
}
@Test
@DisplayName("관리자 목록 조회는 기간과 상태 조건으로 snapshot job을 조회한다")
fun shouldFindJobsByRequestedPeriodAndStatuses() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
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(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = endAt.plusHours(9),
trigger = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.FAILED,
lastError = "aggregate failed",
processingStartedAt = null,
processedAt = null
)
)
jobPort.save(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = endAt.plusHours(9),
trigger = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.DONE,
lastError = null,
processingStartedAt = null,
processedAt = null
)
)
val jobs = service.findJobs(
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
statuses = listOf(CreatorRankingSnapshotJobStatus.FAILED)
)
assertEquals(listOf(failed.id), jobs.map { it.id })
}
@Test
@DisplayName("관리자 실패 job 재시도는 FAILED job만 PENDING으로 되돌린다")
fun shouldRetryOnlyFailedSnapshotJob() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
val failed = jobPort.save(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
trigger = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.FAILED,
lastError = "aggregate failed",
processingStartedAt = LocalDateTime.of(2026, 6, 8, 7, 30),
processedAt = LocalDateTime.of(2026, 6, 8, 7, 31)
)
)
val pending = jobPort.save(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
trigger = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.PENDING,
lastError = "keep",
processingStartedAt = null,
processedAt = null
)
)
service.retryFailedJob(failed.id!!)
service.retryFailedJob(pending.id!!)
service.retryFailedJob(999L)
val retried = jobPort.findById(failed.id!!)!!
val unchanged = jobPort.findById(pending.id!!)!!
assertEquals(CreatorRankingSnapshotJobStatus.PENDING, retried.status)
assertEquals(null, retried.lastError)
assertEquals(null, retried.processingStartedAt)
assertEquals(null, retried.processedAt)
assertEquals(CreatorRankingSnapshotJobStatus.PENDING, unchanged.status)
assertEquals("keep", unchanged.lastError)
}
@Test
@DisplayName("스케줄 job 상태 변경은 job id와 상태를 로그로 남긴다")
fun shouldLogScheduledJobStatusTransitions(output: CapturedOutput) {
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 redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.refreshLastCompletedWeekByScheduledJob()
assertTrue(output.out.contains("event=creator_ranking_snapshot_job_status_changed"))
assertTrue(output.out.contains("jobId=1"))
assertTrue(output.out.contains("trigger=SCHEDULED"))
assertTrue(output.out.contains("status=PROCESSING"))
assertTrue(output.out.contains("status=DONE"))
}
@Test
@DisplayName("실패 job 상태 변경은 실패 상태와 사유를 로그로 남긴다")
fun shouldLogFailedScheduledJobStatusTransition(output: CapturedOutput) {
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 redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(now)
assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob()
}
assertTrue(output.out.contains("event=creator_ranking_snapshot_job_status_changed"))
assertTrue(output.out.contains("jobId=1"))
assertTrue(output.out.contains("trigger=SCHEDULED"))
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) assertEquals(CreatorRankingSnapshotJobStatus.DONE, jobPort.jobs.single().status)
Mockito.verify(refreshService).refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")))
} }
@Test @Test
@DisplayName("스케줄 job refresh는 기간 기반 lock 획득 실패 시 job 생성과 refresh를 건너뛴다") @DisplayName("fallback job이 같은 기간에 3회 이상이면 refresh와 job 생성을 건너뛴다")
fun shouldSkipScheduledJobRefreshWhenPeriodLockNotAcquired() { fun shouldSkipFallbackWhenFallbackLimitReached() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = periodLockRedissonClient(lockAcquired = false) jobPort.fallbackCount = 3
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) val service = service(refreshService, jobPort)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.refreshLastCompletedWeekByScheduledJob() val refreshed = service.refreshLastCompletedWeekByFallback()
assertFalse(refreshed)
assertTrue(jobPort.jobs.isEmpty()) assertTrue(jobPort.jobs.isEmpty())
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now) Mockito.verifyNoInteractions(refreshService)
} }
@Test @Test
@DisplayName("기간 기반 lock은 스냅샷 refresh transaction commit 이후 해제한다") @DisplayName("fallback refresh 실패는 job을 FAILED로 남기고 예외를 전파한다")
fun shouldUnlockPeriodLockAfterRefreshTransactionCommit() { fun shouldMarkFailedWhenFallbackRefreshFails() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = Mockito.mock(RedissonClient::class.java) Mockito.doThrow(IllegalStateException("refresh failed")).`when`(refreshService).refreshLastCompletedWeek(
val lock = Mockito.mock(RLock::class.java) anyZonedDateTime()
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java) )
val transactionStatus = SimpleTransactionStatus() val service = service(refreshService, jobPort)
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 exception = assertThrows(IllegalStateException::class.java) { service.refreshLastCompletedWeekByFallback() }
val inOrder = Mockito.inOrder(transactionManager, lock) assertEquals("refresh failed", exception.message)
inOrder.verify(transactionManager).commit(transactionStatus)
inOrder.verify(lock).unlock()
}
@Test
@DisplayName("스케줄 refresh 실패 시 rollback 이후 별도 transaction으로 FAILED 상태를 커밋한다")
fun shouldCommitFailedStatusAfterRefreshTransactionRollback() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
val saveStatus = SimpleTransactionStatus()
val processingStatus = SimpleTransactionStatus()
val refreshStatus = SimpleTransactionStatus()
val failedStatus = SimpleTransactionStatus()
val now = ZonedDateTime.of(2026, 6, 8, 1, 0, 0, 0, ZoneId.of("Asia/Seoul"))
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
.thenReturn(saveStatus, processingStatus, refreshStatus, failedStatus)
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(now)
val service = CreatorRankingSnapshotJobService(
refreshService,
jobPort,
redissonClient,
transactionManager
) { now }
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob()
}
assertEquals("aggregate failed", exception.message)
assertEquals(CreatorRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status) assertEquals(CreatorRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status)
assertEquals("aggregate failed", jobPort.jobs.single().lastError) assertEquals("refresh failed", jobPort.jobs.single().lastError)
val inOrder = Mockito.inOrder(transactionManager)
inOrder.verify(transactionManager).commit(saveStatus)
inOrder.verify(transactionManager).commit(processingStatus)
inOrder.verify(transactionManager).rollback(refreshStatus)
inOrder.verify(transactionManager).commit(failedStatus)
} }
@Test @Test
@DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 시에만 refresh를 실행한다") @DisplayName("period lock 획득 실패는 fallback을 정상 skip한다")
fun shouldRefreshColdStartSnapshotOnlyWhenPeriodLockAcquired() { fun shouldSkipFallbackWhenLockNotAcquired() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java) val lock = Mockito.mock(RLock::class.java)
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false)
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00" val redissonClient = Mockito.mock(RedissonClient::class.java)
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock) Mockito.`when`(redissonClient.getLock(Mockito.anyString())).thenReturn(lock)
val service = service(refreshService, jobPort, redissonClient)
val refreshed = service.refreshLastCompletedWeekByFallback()
assertFalse(refreshed)
assertTrue(jobPort.jobs.isEmpty())
Mockito.verifyNoInteractions(refreshService)
}
private fun service(
refreshService: CreatorRankingSnapshotRefreshService,
jobPort: CreatorRankingSnapshotJobPort,
redissonClient: RedissonClient = lockedRedisson(),
now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"))
) = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, ImmediateTransactionManager(), { now })
private fun anyZonedDateTime(): ZonedDateTime {
return Mockito.any(ZonedDateTime::class.java) ?: ZonedDateTime.now()
}
private fun lockedRedisson(): RedissonClient {
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).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 redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java) Mockito.`when`(redissonClient.getLock(Mockito.anyString())).thenReturn(lock)
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 return redissonClient
} }
}
private class ImmediateTransactionManager : PlatformTransactionManager {
override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus()
override fun commit(status: TransactionStatus) = Unit
override fun rollback(status: TransactionStatus) = Unit
}
private class FakeCreatorRankingSnapshotJobPort : CreatorRankingSnapshotJobPort { private class FakeCreatorRankingSnapshotJobPort : CreatorRankingSnapshotJobPort {
val jobs = mutableListOf<CreatorRankingSnapshotJobRecord>() val jobs = mutableListOf<CreatorRankingSnapshotJobRecord>()
var fallbackCount = 0L
private var nextId = 1L private var nextId = 1L
override fun save(job: CreatorRankingSnapshotJobRecord): CreatorRankingSnapshotJobRecord { override fun save(job: CreatorRankingSnapshotJobRecord): CreatorRankingSnapshotJobRecord {
val saved = job.copy(id = job.id ?: nextId++) val saved = job.copy(id = nextId++)
jobs.add(saved) jobs.add(saved)
return saved return saved
} }
override fun findById(jobId: Long): CreatorRankingSnapshotJobRecord? { override fun findById(jobId: Long) = jobs.firstOrNull { it.id == jobId }
return jobs.firstOrNull { it.id == jobId }
}
override fun findByPeriodAndStatuses( override fun findByPeriodAndStatuses(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime,
statuses: List<CreatorRankingSnapshotJobStatus> statuses: List<CreatorRankingSnapshotJobStatus>
): List<CreatorRankingSnapshotJobRecord> { ) = jobs.filter { it.status in statuses }
return jobs.filter {
it.aggregationStartAtUtc == aggregationStartAtUtc &&
it.aggregationEndAtUtc == aggregationEndAtUtc &&
it.status in statuses
}
}
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? { override fun countByRankingTypeAndPeriodAndTrigger(
return update(jobId) { rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: CreatorRankingSnapshotJobTrigger
) = fallbackCount
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime) = update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.PROCESSING, status = CreatorRankingSnapshotJobStatus.PROCESSING,
processingStartedAt = processingStartedAt processingStartedAt = processingStartedAt
) )
} }
}
override fun markDone(jobId: Long, processedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? { override fun markDone(jobId: Long, processedAt: LocalDateTime) = update(jobId) {
return update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.DONE, status = CreatorRankingSnapshotJobStatus.DONE,
processedAt = processedAt, processedAt = processedAt
lastError = null
) )
} }
}
override fun markFailed(jobId: Long, processedAt: LocalDateTime, lastError: String?): CreatorRankingSnapshotJobRecord? { override fun markFailed(
return update(jobId) { jobId: Long,
processedAt: LocalDateTime,
lastError: String?
) = update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.FAILED, status = CreatorRankingSnapshotJobStatus.FAILED,
processedAt = processedAt, processedAt = processedAt,
lastError = lastError lastError = lastError
) )
} }
}
override fun markPending(jobId: Long): CreatorRankingSnapshotJobRecord? { override fun markPending(jobId: Long) = update(jobId) {
return update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.PENDING, status = CreatorRankingSnapshotJobStatus.PENDING,
lastError = null, lastError = null,
processingStartedAt = null, processedAt = null,
processedAt = null processingStartedAt = null
) )
} }
}
private fun update( private fun update(
jobId: Long, jobId: Long,
updater: (CreatorRankingSnapshotJobRecord) -> CreatorRankingSnapshotJobRecord mapper: (CreatorRankingSnapshotJobRecord) -> CreatorRankingSnapshotJobRecord
): CreatorRankingSnapshotJobRecord? { ): CreatorRankingSnapshotJobRecord? {
val index = jobs.indexOfFirst { it.id == jobId } val index = jobs.indexOfFirst { it.id == jobId }
if (index < 0) return null if (index < 0) return null
val updated = updater(jobs[index]) jobs[index] = mapper(jobs[index])
jobs[index] = updated return jobs[index]
return updated
} }
} }