fix: harden memoir image generation flow
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
package com.huaga.life_echo.network.models
|
||||
|
||||
const val MEMOIR_IMAGE_STATUS_PENDING = "pending"
|
||||
const val MEMOIR_IMAGE_STATUS_PROCESSING = "processing"
|
||||
const val MEMOIR_IMAGE_STATUS_COMPLETED = "completed"
|
||||
const val MEMOIR_IMAGE_STATUS_FAILED = "failed"
|
||||
@@ -1,13 +1,41 @@
|
||||
package com.huaga.life_echo.ui.screens
|
||||
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PENDING
|
||||
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PROCESSING
|
||||
|
||||
internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 3_000L
|
||||
internal const val MEMOIR_IMAGE_PROVIDER_MAX_ATTEMPTS = 60L
|
||||
internal const val MEMOIR_IMAGE_POLL_GRACE_MS = 30_000L
|
||||
|
||||
internal fun hasPendingMemoirImages(chapters: List<ChapterDto>): Boolean {
|
||||
return chapters.any { chapter ->
|
||||
chapter.images.any { image ->
|
||||
image.status == "pending" || image.status == "processing"
|
||||
image.status == MEMOIR_IMAGE_STATUS_PENDING || image.status == MEMOIR_IMAGE_STATUS_PROCESSING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun longestPendingMemoirImageSequence(chapters: List<ChapterDto>): Long {
|
||||
return chapters.maxOfOrNull { chapter ->
|
||||
chapter.images.count { image ->
|
||||
image.status == MEMOIR_IMAGE_STATUS_PENDING || image.status == MEMOIR_IMAGE_STATUS_PROCESSING
|
||||
}.toLong()
|
||||
} ?: 0L
|
||||
}
|
||||
|
||||
internal fun memoirImagePollTimeoutMs(maxObservedPendingImages: Long): Long {
|
||||
val pendingImages = maxOf(1L, maxObservedPendingImages)
|
||||
return pendingImages * MEMOIR_IMAGE_POLL_INTERVAL_MS * MEMOIR_IMAGE_PROVIDER_MAX_ATTEMPTS +
|
||||
MEMOIR_IMAGE_POLL_GRACE_MS
|
||||
}
|
||||
|
||||
internal fun shouldContinueMemoirImagePolling(
|
||||
chapters: List<ChapterDto>,
|
||||
pollStartedAtMs: Long,
|
||||
nowMs: Long,
|
||||
maxObservedPendingImages: Long,
|
||||
): Boolean {
|
||||
if (!hasPendingMemoirImages(chapters)) return false
|
||||
return nowMs - pollStartedAtMs < memoirImagePollTimeoutMs(maxObservedPendingImages)
|
||||
}
|
||||
|
||||
@@ -168,17 +168,31 @@ fun MyMemoirScreen(
|
||||
val shouldPollChapterImages = remember(chapterDtos) {
|
||||
hasPendingMemoirImages(chapterDtos)
|
||||
}
|
||||
val latestChapterDtos by rememberUpdatedState(chapterDtos)
|
||||
val latestIsLoading by rememberUpdatedState(isLoading)
|
||||
val latestIsRefreshing by rememberUpdatedState(isRefreshing)
|
||||
|
||||
LaunchedEffect(shouldPollChapterImages) {
|
||||
if (!shouldPollChapterImages) return@LaunchedEffect
|
||||
val pollStartedAtMs = System.currentTimeMillis()
|
||||
var maxObservedPendingImages = longestPendingMemoirImageSequence(latestChapterDtos)
|
||||
|
||||
while (true) {
|
||||
while (
|
||||
shouldContinueMemoirImagePolling(
|
||||
chapters = latestChapterDtos,
|
||||
pollStartedAtMs = pollStartedAtMs,
|
||||
nowMs = System.currentTimeMillis(),
|
||||
maxObservedPendingImages = maxObservedPendingImages,
|
||||
)
|
||||
) {
|
||||
delay(MEMOIR_IMAGE_POLL_INTERVAL_MS)
|
||||
if (!latestIsLoading && !latestIsRefreshing) {
|
||||
viewModel.refreshChapters()
|
||||
}
|
||||
maxObservedPendingImages = maxOf(
|
||||
maxObservedPendingImages,
|
||||
longestPendingMemoirImageSequence(latestChapterDtos),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.huaga.life_echo.ui.screens
|
||||
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -28,7 +29,85 @@ class MemoirImagePollingTest {
|
||||
assertFalse(hasPendingMemoirImages(chapters))
|
||||
}
|
||||
|
||||
private fun chapterWithImages(status: String): ChapterDto {
|
||||
@Test
|
||||
fun memoirImagePollTimeoutMs_scalesWithPendingImagesInSameChapter() {
|
||||
val chapters = listOf(
|
||||
chapterWithImages("processing", imageCount = 2),
|
||||
)
|
||||
|
||||
assertTrue(memoirImagePollTimeoutMs(2L) > MEMOIR_IMAGE_POLL_GRACE_MS)
|
||||
assertEquals(
|
||||
390_000L,
|
||||
memoirImagePollTimeoutMs(2L),
|
||||
)
|
||||
assertEquals(2L, longestPendingMemoirImageSequence(chapters))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldContinueMemoirImagePolling_returnsFalse_afterDynamicTimeout() {
|
||||
val chapters = listOf(
|
||||
chapterWithImages("processing", imageCount = 2),
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
shouldContinueMemoirImagePolling(
|
||||
chapters = chapters,
|
||||
pollStartedAtMs = 0L,
|
||||
nowMs = memoirImagePollTimeoutMs(2L) + 1L,
|
||||
maxObservedPendingImages = 2L,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldContinueMemoirImagePolling_returnsTrue_beforeDynamicTimeout_whenPending() {
|
||||
val chapters = listOf(
|
||||
chapterWithImages("processing", imageCount = 2),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
shouldContinueMemoirImagePolling(
|
||||
chapters = chapters,
|
||||
pollStartedAtMs = 0L,
|
||||
nowMs = memoirImagePollTimeoutMs(2L) - 1L,
|
||||
maxObservedPendingImages = 2L,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldContinueMemoirImagePolling_keepsOriginalWindow_whenPendingCountDropsMidSession() {
|
||||
val chapters = listOf(
|
||||
chapterWithImages("processing", imageCount = 1),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
shouldContinueMemoirImagePolling(
|
||||
chapters = chapters,
|
||||
pollStartedAtMs = 0L,
|
||||
nowMs = 300_000L,
|
||||
maxObservedPendingImages = 2L,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldContinueMemoirImagePolling_returnsFalse_whenLatestChapterStateHasNoPendingImages() {
|
||||
val chapters = listOf(
|
||||
chapterWithImages("completed", imageCount = 1),
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
shouldContinueMemoirImagePolling(
|
||||
chapters = chapters,
|
||||
pollStartedAtMs = 0L,
|
||||
nowMs = 1L,
|
||||
maxObservedPendingImages = 1L,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun chapterWithImages(status: String, imageCount: Int = 1): ChapterDto {
|
||||
return ChapterDto(
|
||||
id = "chapter-$status",
|
||||
title = "title-$status",
|
||||
@@ -36,13 +115,13 @@ class MemoirImagePollingTest {
|
||||
order_index = 0,
|
||||
status = "partial",
|
||||
category = "childhood",
|
||||
images = listOf(
|
||||
images = (0 until imageCount).map { index ->
|
||||
ChapterImageDto(
|
||||
index = 0,
|
||||
placeholder = "{{IMAGE:$status}}",
|
||||
index = index,
|
||||
placeholder = "{{IMAGE:$status-$index}}",
|
||||
description = status,
|
||||
prompt = null,
|
||||
url = if (status == "completed") "https://cos.example.com/$status.png" else null,
|
||||
url = if (status == "completed") "https://cos.example.com/$status-$index.png" else null,
|
||||
status = status,
|
||||
provider = "liblib",
|
||||
style = "memoir",
|
||||
@@ -51,7 +130,7 @@ class MemoirImagePollingTest {
|
||||
created_at = null,
|
||||
updated_at = null,
|
||||
)
|
||||
),
|
||||
},
|
||||
updated_at = null,
|
||||
is_new = false,
|
||||
source_segments = emptyList(),
|
||||
|
||||
Reference in New Issue
Block a user