mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 11:24:35 -05:00
Fix/backup import failure not resetting status (#746)
* Reset backup status to idle in case of an exception * Rename "performRestore" function * Set backup status to failure on exception Makes it possible to detect if the restore failed or not after the first status was received * Set backup status to success on completion Since the status is not provided over a subscription, but over a query that should be pulled, it is not really easily detectable if a restore finished or not, since both states will be indicated by "idle" * Correctly wait for first new status when triggering backup import The status is only "Idle" in case no backup import has ever run. Once the first backup process finished it is either "Failure" or "Success" * Rename "ProtoBackupImport::restore" function * Add id to restore process Makes it possible to differentiate between backup restore processes.
This commit is contained in:
@@ -1,13 +1,9 @@
|
|||||||
package suwayomi.tachidesk.graphql.mutations
|
package suwayomi.tachidesk.graphql.mutations
|
||||||
|
|
||||||
import io.javalin.http.UploadedFile
|
import io.javalin.http.UploadedFile
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import suwayomi.tachidesk.graphql.server.TemporaryFileStorage
|
import suwayomi.tachidesk.graphql.server.TemporaryFileStorage
|
||||||
import suwayomi.tachidesk.graphql.types.BackupRestoreState
|
|
||||||
import suwayomi.tachidesk.graphql.types.BackupRestoreStatus
|
import suwayomi.tachidesk.graphql.types.BackupRestoreStatus
|
||||||
import suwayomi.tachidesk.graphql.types.toStatus
|
import suwayomi.tachidesk.graphql.types.toStatus
|
||||||
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
||||||
@@ -25,26 +21,23 @@ class BackupMutation {
|
|||||||
|
|
||||||
data class RestoreBackupPayload(
|
data class RestoreBackupPayload(
|
||||||
val clientMutationId: String?,
|
val clientMutationId: String?,
|
||||||
val status: BackupRestoreStatus,
|
val id: String,
|
||||||
|
val status: BackupRestoreStatus?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
fun restoreBackup(input: RestoreBackupInput): CompletableFuture<RestoreBackupPayload> {
|
fun restoreBackup(input: RestoreBackupInput): CompletableFuture<RestoreBackupPayload> {
|
||||||
val (clientMutationId, backup) = input
|
val (clientMutationId, backup) = input
|
||||||
|
|
||||||
return future {
|
return future {
|
||||||
GlobalScope.launch {
|
val restoreId = ProtoBackupImport.restore(backup.content)
|
||||||
ProtoBackupImport.performRestore(backup.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
val status =
|
|
||||||
withTimeout(10.seconds) {
|
withTimeout(10.seconds) {
|
||||||
ProtoBackupImport.backupRestoreState.first {
|
ProtoBackupImport.notifyFlow.first {
|
||||||
it != ProtoBackupImport.BackupRestoreState.Idle
|
ProtoBackupImport.getRestoreState(restoreId) != null
|
||||||
}.toStatus()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RestoreBackupPayload(clientMutationId, status)
|
RestoreBackupPayload(clientMutationId, restoreId, ProtoBackupImport.getRestoreState(restoreId)?.toStatus())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class BackupQuery {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreStatus(): BackupRestoreStatus {
|
fun restoreStatus(id: String): BackupRestoreStatus? {
|
||||||
return ProtoBackupImport.backupRestoreState.value.toStatus()
|
return ProtoBackupImport.getRestoreState(id)?.toStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
|
|||||||
|
|
||||||
enum class BackupRestoreState {
|
enum class BackupRestoreState {
|
||||||
IDLE,
|
IDLE,
|
||||||
|
SUCCESS,
|
||||||
|
FAILURE,
|
||||||
RESTORING_CATEGORIES,
|
RESTORING_CATEGORIES,
|
||||||
RESTORING_MANGA,
|
RESTORING_MANGA,
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,18 @@ fun ProtoBackupImport.BackupRestoreState.toStatus(): BackupRestoreStatus {
|
|||||||
totalManga = 0,
|
totalManga = 0,
|
||||||
mangaProgress = 0,
|
mangaProgress = 0,
|
||||||
)
|
)
|
||||||
|
is ProtoBackupImport.BackupRestoreState.Success ->
|
||||||
|
BackupRestoreStatus(
|
||||||
|
state = BackupRestoreState.SUCCESS,
|
||||||
|
totalManga = 0,
|
||||||
|
mangaProgress = 0,
|
||||||
|
)
|
||||||
|
is ProtoBackupImport.BackupRestoreState.Failure ->
|
||||||
|
BackupRestoreStatus(
|
||||||
|
state = BackupRestoreState.FAILURE,
|
||||||
|
totalManga = 0,
|
||||||
|
mangaProgress = 0,
|
||||||
|
)
|
||||||
is ProtoBackupImport.BackupRestoreState.RestoringCategories ->
|
is ProtoBackupImport.BackupRestoreState.RestoringCategories ->
|
||||||
BackupRestoreStatus(
|
BackupRestoreStatus(
|
||||||
state = BackupRestoreState.RESTORING_CATEGORIES,
|
state = BackupRestoreState.RESTORING_CATEGORIES,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ object BackupController {
|
|||||||
behaviorOf = { ctx ->
|
behaviorOf = { ctx ->
|
||||||
ctx.future(
|
ctx.future(
|
||||||
future {
|
future {
|
||||||
ProtoBackupImport.performRestore(ctx.bodyAsInputStream())
|
ProtoBackupImport.restoreLegacy(ctx.bodyAsInputStream())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ object BackupController {
|
|||||||
// TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz"
|
// TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz"
|
||||||
ctx.future(
|
ctx.future(
|
||||||
future {
|
future {
|
||||||
ProtoBackupImport.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content)
|
ProtoBackupImport.restoreLegacy(ctx.uploadedFile("backup.proto.gz")!!.content)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ package suwayomi.tachidesk.manga.impl.backup.proto
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
@@ -20,6 +27,7 @@ import org.jetbrains.exposed.sql.insertAndGetId
|
|||||||
import org.jetbrains.exposed.sql.select
|
import org.jetbrains.exposed.sql.select
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.jetbrains.exposed.sql.update
|
import org.jetbrains.exposed.sql.update
|
||||||
|
import suwayomi.tachidesk.graphql.types.toStatus
|
||||||
import suwayomi.tachidesk.manga.impl.Category
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga
|
import suwayomi.tachidesk.manga.impl.CategoryManga
|
||||||
import suwayomi.tachidesk.manga.impl.Manga.clearThumbnail
|
import suwayomi.tachidesk.manga.impl.Manga.clearThumbnail
|
||||||
@@ -38,9 +46,13 @@ import suwayomi.tachidesk.manga.model.table.MangaTable
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.lang.Integer.max
|
import java.lang.Integer.max
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object ProtoBackupImport : ProtoBackupBase() {
|
object ProtoBackupImport : ProtoBackupBase() {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
private var restoreAmount = 0
|
private var restoreAmount = 0
|
||||||
@@ -52,15 +64,93 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
sealed class BackupRestoreState {
|
sealed class BackupRestoreState {
|
||||||
data object Idle : BackupRestoreState()
|
data object Idle : BackupRestoreState()
|
||||||
|
|
||||||
|
data object Success : BackupRestoreState()
|
||||||
|
|
||||||
|
data object Failure : BackupRestoreState()
|
||||||
|
|
||||||
data class RestoringCategories(val totalManga: Int) : BackupRestoreState()
|
data class RestoringCategories(val totalManga: Int) : BackupRestoreState()
|
||||||
|
|
||||||
data class RestoringManga(val current: Int, val totalManga: Int, val title: String) : BackupRestoreState()
|
data class RestoringManga(val current: Int, val totalManga: Int, val title: String) : BackupRestoreState()
|
||||||
}
|
}
|
||||||
|
|
||||||
val backupRestoreState = MutableStateFlow<BackupRestoreState>(BackupRestoreState.Idle)
|
private val backupRestoreIdToState = mutableMapOf<String, BackupRestoreState>()
|
||||||
|
|
||||||
suspend fun performRestore(sourceStream: InputStream): ValidationResult {
|
val notifyFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = DROP_OLDEST)
|
||||||
|
|
||||||
|
fun getRestoreState(id: String): BackupRestoreState? {
|
||||||
|
return backupRestoreIdToState[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRestoreState(
|
||||||
|
id: String,
|
||||||
|
state: BackupRestoreState,
|
||||||
|
) {
|
||||||
|
backupRestoreIdToState[id] = state
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
notifyFlow.emit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupRestoreState(id: String) {
|
||||||
|
val timer = Timer()
|
||||||
|
val delay = 1000L * 60 // 60 seconds
|
||||||
|
|
||||||
|
timer.schedule(
|
||||||
|
object : TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
logger.debug { "cleanupRestoreState: $id (${getRestoreState(id)?.toStatus()?.state})" }
|
||||||
|
backupRestoreIdToState.remove(id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
suspend fun restore(sourceStream: InputStream): String {
|
||||||
|
val restoreId = System.currentTimeMillis().toString()
|
||||||
|
|
||||||
|
logger.info { "restore($restoreId): queued" }
|
||||||
|
|
||||||
|
updateRestoreState(restoreId, BackupRestoreState.Idle)
|
||||||
|
|
||||||
|
GlobalScope.launch {
|
||||||
|
restoreLegacy(sourceStream, restoreId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreId
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreLegacy(
|
||||||
|
sourceStream: InputStream,
|
||||||
|
restoreId: String = "legacy",
|
||||||
|
): ValidationResult {
|
||||||
return backupMutex.withLock {
|
return backupMutex.withLock {
|
||||||
|
try {
|
||||||
|
logger.info { "restore($restoreId): restoring..." }
|
||||||
|
performRestore(restoreId, sourceStream)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "restore($restoreId): failed due to" }
|
||||||
|
|
||||||
|
updateRestoreState(restoreId, BackupRestoreState.Failure)
|
||||||
|
ValidationResult(
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
logger.info { "restore($restoreId): finished with state ${getRestoreState(restoreId)?.toStatus()?.state}" }
|
||||||
|
cleanupRestoreState(restoreId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performRestore(
|
||||||
|
id: String,
|
||||||
|
sourceStream: InputStream,
|
||||||
|
): ValidationResult {
|
||||||
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
|
val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() }
|
||||||
val backup = parser.decodeFromByteArray(BackupSerializer, backupString)
|
val backup = parser.decodeFromByteArray(BackupSerializer, backupString)
|
||||||
|
|
||||||
@@ -68,7 +158,7 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
|
|
||||||
restoreAmount = backup.backupManga.size + 1 // +1 for categories
|
restoreAmount = backup.backupManga.size + 1 // +1 for categories
|
||||||
|
|
||||||
backupRestoreState.value = BackupRestoreState.RestoringCategories(backup.backupManga.size)
|
updateRestoreState(id, BackupRestoreState.RestoringCategories(backup.backupManga.size))
|
||||||
// Restore categories
|
// Restore categories
|
||||||
if (backup.backupCategories.isNotEmpty()) {
|
if (backup.backupCategories.isNotEmpty()) {
|
||||||
restoreCategories(backup.backupCategories)
|
restoreCategories(backup.backupCategories)
|
||||||
@@ -77,10 +167,11 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
val categoryMapping =
|
val categoryMapping =
|
||||||
transaction {
|
transaction {
|
||||||
backup.backupCategories.associate {
|
backup.backupCategories.associate {
|
||||||
val dbCategory = CategoryTable.select { CategoryTable.name eq it.name }.firstOrNull()
|
val dbCategory =
|
||||||
|
CategoryTable.select { CategoryTable.name eq it.name }
|
||||||
|
.firstOrNull()
|
||||||
val categoryId =
|
val categoryId =
|
||||||
dbCategory?.let {
|
dbCategory?.let { categoryResultRow ->
|
||||||
categoryResultRow ->
|
|
||||||
categoryResultRow[CategoryTable.id].value
|
categoryResultRow[CategoryTable.id].value
|
||||||
} ?: Category.DEFAULT_CATEGORY_ID
|
} ?: Category.DEFAULT_CATEGORY_ID
|
||||||
it.order to categoryId
|
it.order to categoryId
|
||||||
@@ -92,12 +183,15 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
|
|
||||||
// Restore individual manga
|
// Restore individual manga
|
||||||
backup.backupManga.forEachIndexed { index, manga ->
|
backup.backupManga.forEachIndexed { index, manga ->
|
||||||
backupRestoreState.value =
|
updateRestoreState(
|
||||||
|
id,
|
||||||
BackupRestoreState.RestoringManga(
|
BackupRestoreState.RestoringManga(
|
||||||
current = index + 1,
|
current = index + 1,
|
||||||
totalManga = backup.backupManga.size,
|
totalManga = backup.backupManga.size,
|
||||||
title = manga.title,
|
title = manga.title,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
restoreManga(
|
restoreManga(
|
||||||
backupManga = manga,
|
backupManga = manga,
|
||||||
backupCategories = backup.backupCategories,
|
backupCategories = backup.backupCategories,
|
||||||
@@ -118,10 +212,10 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
${validationResult.missingTrackers.joinToString("\n ")}
|
${validationResult.missingTrackers.joinToString("\n ")}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
backupRestoreState.value = BackupRestoreState.Idle
|
|
||||||
|
|
||||||
validationResult
|
updateRestoreState(id, BackupRestoreState.Success)
|
||||||
}
|
|
||||||
|
return validationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreCategories(backupCategories: List<BackupCategory>) {
|
private fun restoreCategories(backupCategories: List<BackupCategory>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user