feat(i18n): 번역 작업 큐와 언어 감지 캐시를 도입한다
조회 중 외부 번역 호출을 줄이고 누락 번역을 비동기 job으로 처리한다.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SourceTextNormalizerTest {
|
||||
@Test
|
||||
fun shouldNormalizeWhitespaceAndUnicodeBeforeHashing() {
|
||||
val composed = "카페\n\t소개"
|
||||
val decomposed = "카페 소개"
|
||||
|
||||
assertEquals("카페 소개", SourceTextNormalizer.normalize(composed))
|
||||
assertEquals(SourceTextNormalizer.hash(composed), SourceTextNormalizer.hash(decomposed))
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
|
||||
class TranslationJobSchedulerTest {
|
||||
@Test
|
||||
fun shouldCreateOnePendingJobForMissingNormalizedText() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val scheduler = TranslationJobScheduler(jobRepository)
|
||||
|
||||
Mockito.`when`(
|
||||
jobRepository.findActiveJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
targetLanguage = "en",
|
||||
sourceHash = SourceTextNormalizer.hash("제목")
|
||||
)
|
||||
).thenReturn(null)
|
||||
|
||||
scheduler.scheduleMissingTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceText = " 제목\n",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "EN"
|
||||
)
|
||||
|
||||
val captor = ArgumentCaptor.forClass(TranslationJob::class.java)
|
||||
Mockito.verify(jobRepository).save(captor.capture())
|
||||
|
||||
val saved = captor.value
|
||||
assertEquals(LanguageTranslationTargetType.CONTENT, saved.resourceType)
|
||||
assertEquals(10L, saved.resourceId)
|
||||
assertEquals("title", saved.fieldKey)
|
||||
assertEquals("제목", saved.sourceText)
|
||||
assertEquals(SourceTextNormalizer.hash("제목"), saved.sourceHash)
|
||||
assertEquals("ko", saved.sourceLanguage)
|
||||
assertEquals("en", saved.targetLanguage)
|
||||
assertEquals(TranslationJobStatus.PENDING, saved.status)
|
||||
assertNotNull(saved.nextRetryAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotCreateDuplicateJobWhenSameCompletedJobAlreadyExists() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val scheduler = TranslationJobScheduler(jobRepository)
|
||||
val sourceHash = SourceTextNormalizer.hash("제목")
|
||||
|
||||
Mockito.`when`(
|
||||
jobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
targetLanguage = "en",
|
||||
sourceHash = sourceHash
|
||||
)
|
||||
).thenReturn(
|
||||
TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceHash = sourceHash,
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en",
|
||||
status = TranslationJobStatus.COMPLETED
|
||||
)
|
||||
)
|
||||
|
||||
scheduler.scheduleMissingTranslation(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en"
|
||||
)
|
||||
|
||||
Mockito.verify(jobRepository, Mockito.never()).save(Mockito.any(TranslationJob::class.java))
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
class TranslationJobWorkerTest {
|
||||
@Test
|
||||
fun shouldRunEveryTenMinutesByDefault() {
|
||||
val scheduled = TranslationJobWorker::class.java
|
||||
.getDeclaredMethod("runPendingJobs")
|
||||
.getAnnotation(Scheduled::class.java)
|
||||
|
||||
assertEquals("\${sodalive.translation-job.fixed-delay-ms:600000}", scheduled.fixedDelayString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldClaimPendingJobByLockedRepositoryMethod() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = successfulProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val job = translationJob()
|
||||
job.id = 100L
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(100L)
|
||||
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
|
||||
|
||||
worker.processNextJob()
|
||||
|
||||
Mockito.verify(jobRepository).findNextPendingJobIdForUpdate(anyLocalDateTime())
|
||||
Mockito.verify(jobRepository, Mockito.never())
|
||||
.findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc(
|
||||
anyTranslationJobStatus(),
|
||||
anyLocalDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldRetryFailedJobWithBackoff() {
|
||||
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
|
||||
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
|
||||
val provider = failingProvider()
|
||||
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
|
||||
val worker = TranslationJobWorker(
|
||||
translationJobRepository = jobRepository,
|
||||
translationMemoryRepository = memoryRepository,
|
||||
translationProvider = provider,
|
||||
materializer = materializer,
|
||||
transactionManager = TestTransactionManager()
|
||||
)
|
||||
val job = translationJob()
|
||||
job.id = 200L
|
||||
val beforeRetryAt = job.nextRetryAt
|
||||
|
||||
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(200L)
|
||||
Mockito.`when`(jobRepository.findById(200L)).thenReturn(Optional.of(job))
|
||||
|
||||
worker.processNextJob()
|
||||
|
||||
assertEquals(TranslationJobStatus.PENDING, job.status)
|
||||
assertEquals(1, job.retryCount)
|
||||
assertEquals("provider down", job.lastErrorMessage)
|
||||
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
|
||||
}
|
||||
|
||||
private fun translationJob(): TranslationJob {
|
||||
return TranslationJob(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = 10L,
|
||||
fieldKey = "title",
|
||||
sourceHash = SourceTextNormalizer.hash("제목"),
|
||||
sourceText = "제목",
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = "en"
|
||||
)
|
||||
}
|
||||
|
||||
private fun successfulProvider(): TranslationProvider {
|
||||
return object : TranslationProvider {
|
||||
override val providerName: String = "papago"
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
return TranslateResult(listOf("title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun failingProvider(): TranslationProvider {
|
||||
return object : TranslationProvider {
|
||||
override val providerName: String = "papago"
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
throw IllegalStateException("provider down")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun anyLocalDateTime(): LocalDateTime {
|
||||
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||
}
|
||||
|
||||
private fun anyTranslationJobStatus(): TranslationJobStatus {
|
||||
return Mockito.any(TranslationJobStatus::class.java) ?: TranslationJobStatus.PENDING
|
||||
}
|
||||
}
|
||||
|
||||
private class TestTransactionManager : AbstractPlatformTransactionManager() {
|
||||
override fun doGetTransaction(): Any {
|
||||
return Any()
|
||||
}
|
||||
|
||||
override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {
|
||||
}
|
||||
|
||||
override fun doCommit(status: DefaultTransactionStatus) {
|
||||
}
|
||||
|
||||
override fun doRollback(status: DefaultTransactionStatus) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user