diff --git a/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt b/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt index ade3848..9c9ccea 100644 --- a/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt +++ b/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt @@ -4,9 +4,11 @@ import com.maechuri.mainserver.storage.config.RemoveBgProperties import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication @EnableConfigurationProperties(RemoveBgProperties::class) +@EnableScheduling class MainServerApplication fun main(args: Array) { diff --git a/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt b/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt new file mode 100644 index 0000000..bc4b1ee --- /dev/null +++ b/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt @@ -0,0 +1,127 @@ +package com.maechuri.mainserver.scenario.scheduler + +import com.maechuri.mainserver.admin.AdminService +import com.maechuri.mainserver.scenario.client.AiClient +import com.maechuri.mainserver.scenario.dto.ScenarioCreateStatus +import com.maechuri.mainserver.scenario.repository.ScenarioRepository +import com.maechuri.mainserver.scenario.service.ScenarioGenerationService +import com.maechuri.mainserver.storage.service.ImageGenerationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.reactor.awaitSingleOrNull +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.LocalDate + +private val log = KotlinLogging.logger {} + +@Component +class ScenarioDailyScheduler( + private val scenarioGenerationService: ScenarioGenerationService, + @Qualifier("scenario_ai_client") private val aiClient: AiClient, + private val imageGenerationService: ImageGenerationService, + private val adminService: AdminService, + private val scenarioRepository: ScenarioRepository, +) { + + companion object { + const val MAX_ATTEMPTS = 5 + const val POLL_INTERVAL_MS = 30_000L + const val MAX_POLL_COUNT = 240 // 240 × 30 s = 2 hours + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @EventListener(ApplicationReadyEvent::class) + fun onApplicationReady() { + scope.launch { + generateScenarioWithRetry() + } + } + + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + fun scheduleScenarioGeneration() { + scope.launch { + generateScenarioWithRetry() + } + } + + internal suspend fun generateScenarioWithRetry() { + val targetDate = LocalDate.now().plusDays(1) + val existing = scenarioRepository.findByDate(targetDate).awaitSingleOrNull() + if (existing != null) { + log.info { "Scenario for $targetDate already exists (id=${existing.scenarioId}), skipping generation" } + return + } + for (attempt in 1..MAX_ATTEMPTS) { + log.info { "Scenario generation attempt $attempt/$MAX_ATTEMPTS (target date: $targetDate)" } + val success = tryGenerateScenario(targetDate) + if (success) { + log.info { "Scenario generation succeeded on attempt $attempt" } + return + } + log.warn { "Scenario generation attempt $attempt failed" } + } + log.error { "Scenario generation failed after $MAX_ATTEMPTS attempts" } + } + + private suspend fun tryGenerateScenario(targetDate: LocalDate): Boolean { + return try { + val response = scenarioGenerationService.startGeneration("random") + log.info { "Scenario generation started, key=${response.key}" } + pollUntilComplete(response.key, targetDate) + } catch (e: Exception) { + log.error(e) { "Scenario generation threw an exception" } + false + } + } + + private suspend fun pollUntilComplete(key: String, targetDate: LocalDate): Boolean { + repeat(MAX_POLL_COUNT) { + delay(POLL_INTERVAL_MS) + val status = try { + aiClient.getScenarioCreateTask(key) + } catch (e: Exception) { + log.error(e) { "Failed to poll status for key=$key" } + return false + } + log.info { "Poll result for key=$key: status=${status.status}" } + when (status.status) { + ScenarioCreateStatus.COMPLETED -> { + val scenarioId = status.scenarioId ?: run { + log.error { "Scenario completed but scenarioId is null for key=$key" } + return false + } + return onScenarioCompleted(scenarioId, targetDate) + } + ScenarioCreateStatus.FAILED -> { + log.warn { "AI server reported FAILED for key=$key: ${status.error}" } + return false + } + else -> { /* PENDING or PROCESSING – keep polling */ } + } + } + log.error { "Polling timed out after $MAX_POLL_COUNT attempts for key=$key" } + return false + } + + private suspend fun onScenarioCompleted(scenarioId: Long, targetDate: LocalDate): Boolean { + return try { + log.info { "Generating images for scenario $scenarioId" } + imageGenerationService.generateImagesForScenario(scenarioId) + adminService.updateScenarioDate(scenarioId, targetDate) + log.info { "Scenario $scenarioId scheduled for $targetDate" } + true + } catch (e: Exception) { + log.error(e) { "Failed to finalize scenario $scenarioId" } + false + } + } +} diff --git a/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt b/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt new file mode 100644 index 0000000..3862512 --- /dev/null +++ b/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt @@ -0,0 +1,186 @@ +package com.maechuri.mainserver.scenario.scheduler + +import com.maechuri.mainserver.admin.AdminService +import com.maechuri.mainserver.scenario.client.AiClient +import com.maechuri.mainserver.scenario.dto.ScenarioCreateResponse +import com.maechuri.mainserver.scenario.dto.ScenarioCreateStatus +import com.maechuri.mainserver.scenario.dto.ScenarioStatusResponse +import com.maechuri.mainserver.scenario.entity.Difficulty +import com.maechuri.mainserver.scenario.entity.Scenario +import com.maechuri.mainserver.scenario.repository.ScenarioRepository +import com.maechuri.mainserver.scenario.service.ScenarioGenerationService +import com.maechuri.mainserver.storage.service.ImageGenerationService +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import reactor.core.publisher.Mono +import java.sql.Time +import java.sql.Timestamp +import java.time.LocalDate +import java.time.LocalTime + +class ScenarioDailySchedulerTest { + + private val scenarioGenerationService: ScenarioGenerationService = mock() + private val aiClient: AiClient = mock() + private val imageGenerationService: ImageGenerationService = mock() + private val adminService: AdminService = mock() + private val scenarioRepository: ScenarioRepository = mock() + + private val scheduler = ScenarioDailyScheduler( + scenarioGenerationService, aiClient, imageGenerationService, adminService, scenarioRepository + ) + + private fun createResponse(key: String = "test-key") = + ScenarioCreateResponse(key = key, message = "ok", status = ScenarioCreateStatus.PENDING, theme = "random") + + private fun statusResponse(status: ScenarioCreateStatus, scenarioId: Long? = null, error: String? = null) = + ScenarioStatusResponse(key = "test-key", status = status, theme = "random", scenarioId = scenarioId, error = error) + + private fun scenarioEntity(id: Long = 1L, date: LocalDate? = null) = Scenario( + scenarioId = id, + difficulty = Difficulty.easy, + theme = "Theme", + tone = "Tone", + language = "ko", + incidentType = "Type", + incidentSummary = "Summary", + incidentTimeStart = Time.valueOf(LocalTime.NOON), + incidentTimeEnd = Time.valueOf(LocalTime.MIDNIGHT), + primaryObject = "Object", + crimeTimeStart = Time.valueOf(LocalTime.NOON), + crimeTimeEnd = Time.valueOf(LocalTime.MIDNIGHT), + crimeMethod = "Method", + noSupernatural = true, + noTimeTravel = true, + createdAt = Timestamp(System.currentTimeMillis()), + incidentLocationId = null, + crimeLocationId = null, + date = date, + ) + + @Test + fun `generateScenarioWithRetry skips when scenario for tomorrow already exists`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.just(scenarioEntity(date = LocalDate.now().plusDays(1)))) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, never()).startGeneration(any()) + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry succeeds on first attempt`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = 1L)) + + scheduler.generateScenarioWithRetry() + + verify(imageGenerationService, times(1)).generateImagesForScenario(1L) + verify(adminService, times(1)).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry retries on FAILED and succeeds on second attempt`() = runTest { + val firstKey = "key-1" + val secondKey = "key-2" + + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) + whenever(scenarioGenerationService.startGeneration(any())) + .thenReturn(createResponse(firstKey)) + .thenReturn(createResponse(secondKey)) + whenever(aiClient.getScenarioCreateTask(firstKey)) + .thenReturn(statusResponse(ScenarioCreateStatus.FAILED)) + whenever(aiClient.getScenarioCreateTask(secondKey)) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = 2L)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(2)).startGeneration(any()) + verify(imageGenerationService, times(1)).generateImagesForScenario(2L) + verify(adminService, times(1)).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry stops after MAX_ATTEMPTS`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.FAILED)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(ScenarioDailyScheduler.MAX_ATTEMPTS)).startGeneration(any()) + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry returns false when startGeneration throws`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) + whenever(scenarioGenerationService.startGeneration(any())) + .thenThrow(RuntimeException("AI server unavailable")) + + scheduler.generateScenarioWithRetry() + + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + verify(scenarioGenerationService, times(ScenarioDailyScheduler.MAX_ATTEMPTS)).startGeneration(any()) + } + + @Test + fun `generateScenarioWithRetry returns false when scenarioId is null on COMPLETED`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = null)) + + scheduler.generateScenarioWithRetry() + + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry stops polling after MAX_POLL_COUNT and retries`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + // Always return PROCESSING so the poll loop times out + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.PROCESSING)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(ScenarioDailyScheduler.MAX_ATTEMPTS)).startGeneration(any()) + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry retries when polling throws exception`() = runTest { + val firstKey = "key-1" + val secondKey = "key-2" + + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) + whenever(scenarioGenerationService.startGeneration(any())) + .thenReturn(createResponse(firstKey)) + .thenReturn(createResponse(secondKey)) + whenever(aiClient.getScenarioCreateTask(firstKey)) + .thenThrow(RuntimeException("network error")) + whenever(aiClient.getScenarioCreateTask(secondKey)) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = 3L)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(2)).startGeneration(any()) + verify(imageGenerationService, times(1)).generateImagesForScenario(3L) + } +}