Batch database operations during backup restore for improved performance (#3267)

* perf(backup): batch database operations during restore

Reduce overhead by chunking manga and repo restoration into
transactions of 100 entries. Categories now restored in a single
transaction. Optimized DatabaseHandler to reuse existing transaction
contexts instead of creating redundant nested ones.

Changed TransactionElement to internal to allow AndroidDatabaseHandler
to check transaction state from the same package.

* fix(backup): make restore progress and errors thread-safe

Use AtomicInteger for progress and synchronizedList for errors to
prevent race conditions during concurrent restoration coroutines.

* perf(backup): update progress notification only once per chunk

Reduce UI/IPC overhead by moving notification updates out of the inner
loop, triggering them only after each 100-item transaction chunk.

Reduced on my emulator from 46 seconds to 27

* fix: remove invalid awaitAsOne() call on insert() return type

categoriesQueries.insert() returns Long (the inserted row ID), not an
ExecutableQuery<T>, so awaitAsOne() is not applicable.

* fix(feedback): Use `CopyOnWriteArrayList` and kotlin `AtomicInt`, add a changelog entry

Note that using kotlin `AtomicInt` instead of java `AtomicInteger` requires opting into an experimental API.

---------

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
Luca Auer
2026-06-23 15:45:25 +02:00
committed by GitHub
parent c397d657d1
commit 06497622f6
3 changed files with 62 additions and 38 deletions
+3
View File
@@ -20,6 +20,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Change the term "Obsolete" to "Orphaned" for extensions ([@AntsyLich](https://github.com/AntsyLich)) ([#3383](https://github.com/mihonapp/mihon/pull/3383)) - Change the term "Obsolete" to "Orphaned" for extensions ([@AntsyLich](https://github.com/AntsyLich)) ([#3383](https://github.com/mihonapp/mihon/pull/3383))
- Remove text limit of manga notes ([@AntsyLich](https://github.com/AntsyLich)) ([#3410](https://github.com/mihonapp/mihon/pull/3410)) - Remove text limit of manga notes ([@AntsyLich](https://github.com/AntsyLich)) ([#3410](https://github.com/mihonapp/mihon/pull/3410))
### Improved
- Batch database operations during backup restore for improved performance ([@Lolle2000la](https://github.com/Lolle2000la)) ([#3267](https://github.com/mihonapp/mihon/pull/3267))
### Fixed ### Fixed
- Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184)) - Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184))
- Continue reading button missing when unread filter is off ([@AntsyLich](https://github.com/AntsyLich)) ([#3382](https://github.com/mihonapp/mihon/pull/3382)) - Continue reading button missing when unread filter is off ([@AntsyLich](https://github.com/AntsyLich)) ([#3382](https://github.com/mihonapp/mihon/pull/3382))
@@ -19,17 +19,26 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.data.Database
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.incrementAndFetch
@OptIn(ExperimentalAtomicApi::class)
class BackupRestorer( class BackupRestorer(
private val context: Context, private val context: Context,
private val notifier: BackupNotifier, private val notifier: BackupNotifier,
private val isSync: Boolean, private val isSync: Boolean,
private val database: Database = Injekt.get(),
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(), private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context), private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(), private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
@@ -37,8 +46,8 @@ class BackupRestorer(
) { ) {
private var restoreAmount = 0 private var restoreAmount = 0
private var restoreProgress = 0 private val restoreProgress = AtomicInt(0)
private val errors = mutableListOf<Pair<Date, String>>() private val errors = CopyOnWriteArrayList<Pair<Date, String>>()
/** /**
* Mapping of source ID to source name from backup data * Mapping of source ID to source name from backup data
@@ -111,10 +120,10 @@ class BackupRestorer(
ensureActive() ensureActive()
categoriesRestorer(backupCategories) categoriesRestorer(backupCategories)
restoreProgress += 1 val progress = restoreProgress.incrementAndFetch()
notifier.showRestoreProgress( notifier.showRestoreProgress(
context.stringResource(MR.strings.categories), context.stringResource(MR.strings.categories),
restoreProgress, progress,
restoreAmount, restoreAmount,
isSync, isSync,
) )
@@ -125,18 +134,23 @@ class BackupRestorer(
backupCategories: List<BackupCategory>, backupCategories: List<BackupCategory>,
) = launch { ) = launch {
mangaRestorer.sortByNew(backupMangas) mangaRestorer.sortByNew(backupMangas)
.forEach { .chunked(100)
ensureActive() .forEach { chunk ->
database.transaction {
chunk.forEach {
ensureActive()
try { try {
mangaRestorer.restore(it, backupCategories) mangaRestorer.restore(it, backupCategories)
} catch (e: Exception) { } catch (e: Exception) {
val sourceName = sourceMapping[it.source] ?: it.source.toString() val sourceName = sourceMapping[it.source] ?: it.source.toString()
errors.add(Date() to "${it.title} [$sourceName]: ${e.message}") errors.add(Date() to "${it.title} [$sourceName]: ${e.message}")
}
restoreProgress.incrementAndFetch()
}
} }
notifier.showRestoreProgress(chunk.last().title, restoreProgress.load(), restoreAmount, isSync)
restoreProgress += 1
notifier.showRestoreProgress(it.title, restoreProgress, restoreAmount, isSync)
} }
} }
@@ -150,10 +164,10 @@ class BackupRestorer(
categories, categories,
) )
restoreProgress += 1 val progress = restoreProgress.incrementAndFetch()
notifier.showRestoreProgress( notifier.showRestoreProgress(
context.stringResource(MR.strings.app_settings), context.stringResource(MR.strings.app_settings),
restoreProgress, progress,
restoreAmount, restoreAmount,
isSync, isSync,
) )
@@ -163,10 +177,10 @@ class BackupRestorer(
ensureActive() ensureActive()
preferenceRestorer.restoreSource(preferences) preferenceRestorer.restoreSource(preferences)
restoreProgress += 1 val progress = restoreProgress.incrementAndFetch()
notifier.showRestoreProgress( notifier.showRestoreProgress(
context.stringResource(MR.strings.source_settings), context.stringResource(MR.strings.source_settings),
restoreProgress, progress,
restoreAmount, restoreAmount,
isSync, isSync,
) )
@@ -176,19 +190,24 @@ class BackupRestorer(
backupExtensionStores: List<BackupExtensionStore>, backupExtensionStores: List<BackupExtensionStore>,
) = launch { ) = launch {
backupExtensionStores backupExtensionStores
.forEach { .chunked(100)
ensureActive() .forEach { chunk ->
database.transaction {
chunk.forEach {
ensureActive()
try { try {
extensionStoreRestorer(it) extensionStoreRestorer(it)
} catch (e: Exception) { } catch (e: Exception) {
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}") errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
}
restoreProgress.incrementAndFetch()
}
} }
restoreProgress += 1
notifier.showRestoreProgress( notifier.showRestoreProgress(
context.stringResource(MR.strings.extensionStores), context.stringResource(MR.strings.extensionStores),
restoreProgress, restoreProgress.load(),
restoreAmount, restoreAmount,
isSync, isSync,
) )
@@ -208,7 +227,7 @@ class BackupRestorer(
} }
return file return file
} }
} catch (e: Exception) { } catch (_: Exception) {
// Empty // Empty
} }
return File("") return File("")
@@ -19,16 +19,18 @@ class CategoriesRestorer(
val dbCategoriesByName = dbCategories.associateBy { it.name } val dbCategoriesByName = dbCategories.associateBy { it.name }
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0 var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
val categories = backupCategories val categories = database.transactionWithResult {
.sortedBy { it.order } backupCategories
.map { .sortedBy { it.order }
val dbCategory = dbCategoriesByName[it.name] .map {
if (dbCategory != null) return@map dbCategory val dbCategory = dbCategoriesByName[it.name]
val order = nextOrder++ if (dbCategory != null) return@map dbCategory
database.categoriesQueries val order = nextOrder++
.insert(it.name, order, it.flags) database.categoriesQueries
.let { id -> it.toCategory(id).copy(order = order) } .insert(it.name, order, it.flags)
} .let { id -> it.toCategory(id).copy(order = order) }
}
}
libraryPreferences.categorizedDisplaySettings.set( libraryPreferences.categorizedDisplaySettings.set(
(dbCategories + categories) (dbCategories + categories)