feat(i18n): 번역 작업을 그룹 단위로 처리한다

This commit is contained in:
2026-05-06 20:21:29 +09:00
parent 3a0c30e340
commit 13ca6a97b9
4 changed files with 181 additions and 54 deletions

View File

@@ -33,22 +33,26 @@ interface TranslationJobRepository : JpaRepository<TranslationJob, Long> {
@Param("sourceHash") sourceHash: String
): TranslationJob?
fun findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc(
status: TranslationJobStatus,
nextRetryAt: LocalDateTime
): TranslationJob?
@Query(
value = """
select id
from translation_job
where status = 'PENDING'
and next_retry_at <= :now
order by created_at asc
limit 1
select j.id
from translation_job j
join (
select resource_type, resource_id, target_language
from translation_job
where status = 'PENDING'
and next_retry_at <= :now
order by created_at asc
limit 1
) g on j.resource_type = g.resource_type
and j.resource_id = g.resource_id
and j.target_language = g.target_language
where j.status = 'PENDING'
and j.next_retry_at <= :now
order by j.created_at asc
for update skip locked
""",
nativeQuery = true
)
fun findNextPendingJobIdForUpdate(@Param("now") now: LocalDateTime): Long?
fun findNextPendingGroupJobIdsForUpdate(@Param("now") now: LocalDateTime): List<Long>
}

View File

@@ -20,33 +20,52 @@ class TranslationJobWorker(
@Scheduled(fixedDelayString = "\${sodalive.translation-job.fixed-delay-ms:600000}")
fun runPendingJobs() {
repeat(MAX_JOBS_PER_TICK) {
if (!processNextJob()) return
repeat(MAX_GROUPS_PER_TICK) {
if (!processNextGroup()) return
}
}
fun processNextJob(): Boolean {
val job = claimNextJob() ?: return false
fun processNextGroup(): Boolean {
val jobs = claimNextGroup()
if (jobs.isEmpty()) return false
val firstJob = jobs.first()
val succeededJobs = mutableListOf<TranslationJob>()
val failedJobs = mutableListOf<Pair<TranslationJob, Exception>>()
jobs.forEach { job ->
try {
ensureMemory(job)
succeededJobs.add(job)
} catch (ex: Exception) {
failedJobs.add(job to ex)
}
}
if (failedJobs.isNotEmpty()) {
succeededJobs.forEach { completeJob(it.id!!) }
failedJobs.forEach { (job, ex) -> failJob(job.id!!, ex) }
return true
}
try {
ensureMemory(job)
materializer.materialize(job.resourceType, job.resourceId, job.targetLanguage)
completeJob(job.id!!)
materializer.materialize(firstJob.resourceType, firstJob.resourceId, firstJob.targetLanguage)
succeededJobs.forEach { completeJob(it.id!!) }
} catch (ex: Exception) {
failJob(job.id!!, ex)
succeededJobs.forEach { failJob(it.id!!, ex) }
}
return true
}
private fun claimNextJob(): TranslationJob? {
private fun claimNextGroup(): List<TranslationJob> {
return transactionTemplate.execute {
val jobId = translationJobRepository.findNextPendingJobIdForUpdate(LocalDateTime.now())
?: return@execute null
val job = translationJobRepository.findById(jobId).orElse(null)
?: return@execute null
job.status = TranslationJobStatus.RUNNING
translationJobRepository.save(job)
job
}
val jobIds = translationJobRepository.findNextPendingGroupJobIdsForUpdate(LocalDateTime.now())
jobIds.mapNotNull { jobId ->
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@mapNotNull null
job.status = TranslationJobStatus.RUNNING
translationJobRepository.save(job)
job
}
}.orEmpty()
}
private fun ensureMemory(job: TranslationJob) {
@@ -129,7 +148,7 @@ class TranslationJobWorker(
}
companion object {
private const val MAX_JOBS_PER_TICK = 20
private const val MAX_GROUPS_PER_TICK = 5
private const val MAX_ERROR_LENGTH = 1000
private const val MAX_RETRY_COUNT = 3
}