Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading