Skip to content

Commit cff199f

Browse files
authored
Merge pull request #458 from synonymdev/refactor/backup-perf-optimisation
feat: optimise backup & restore for performance
2 parents e7199d0 + b5aace7 commit cff199f

File tree

13 files changed

+111
-64
lines changed

13 files changed

+111
-64
lines changed

app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import kotlinx.coroutines.CoroutineDispatcher
1010
import kotlinx.coroutines.withContext
1111
import kotlinx.coroutines.withTimeout
1212
import to.bitkit.data.keychain.Keychain
13-
import to.bitkit.di.BgDispatcher
13+
import to.bitkit.di.IoDispatcher
1414
import to.bitkit.env.Env
1515
import to.bitkit.utils.Logger
1616
import to.bitkit.utils.ServiceError
@@ -20,13 +20,13 @@ import kotlin.time.Duration.Companion.seconds
2020

2121
@Singleton
2222
class VssBackupClient @Inject constructor(
23-
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
23+
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
2424
private val vssStoreIdProvider: VssStoreIdProvider,
2525
private val keychain: Keychain,
2626
) {
27-
private val isSetup = CompletableDeferred<Unit>()
27+
private var isSetup = CompletableDeferred<Unit>()
2828

29-
suspend fun setup(walletIndex: Int = 0) = withContext(bgDispatcher) {
29+
suspend fun setup(walletIndex: Int = 0) = withContext(ioDispatcher) {
3030
try {
3131
withTimeout(30.seconds) {
3232
Logger.debug("VSS client setting up…", context = TAG)
@@ -62,10 +62,18 @@ class VssBackupClient @Inject constructor(
6262
}
6363
}
6464

65+
fun reset() {
66+
synchronized(this) {
67+
isSetup.cancel()
68+
isSetup = CompletableDeferred()
69+
}
70+
vssStoreIdProvider.clearCache()
71+
Logger.debug("VSS client reset", context = TAG)
72+
}
6573
suspend fun putObject(
6674
key: String,
6775
data: ByteArray,
68-
): Result<VssItem> = withContext(bgDispatcher) {
76+
): Result<VssItem> = withContext(ioDispatcher) {
6977
isSetup.await()
7078
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
7179
runCatching {
@@ -80,7 +88,7 @@ class VssBackupClient @Inject constructor(
8088
}
8189
}
8290

83-
suspend fun getObject(key: String): Result<VssItem?> = withContext(bgDispatcher) {
91+
suspend fun getObject(key: String): Result<VssItem?> = withContext(ioDispatcher) {
8492
isSetup.await()
8593
Logger.verbose("VSS 'getObject' call for '$key'", context = TAG)
8694
runCatching {

app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ class VssStoreIdProvider @Inject constructor(
3434
}
3535
}
3636

37-
fun clearCache(walletIndex: Int = 0) {
38-
cacheMap.remove(walletIndex)
37+
fun clearCache() {
38+
cacheMap.clear()
3939
}
4040

4141
companion object {

app/src/main/java/to/bitkit/di/DispatchersModule.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,12 @@ import kotlinx.coroutines.Dispatchers
1111
import javax.inject.Qualifier
1212

1313
@Qualifier
14-
@Retention(AnnotationRetention.BINARY)
1514
annotation class UiDispatcher
1615

1716
@Qualifier
18-
@Retention(AnnotationRetention.BINARY)
1917
annotation class BgDispatcher
2018

2119
@Qualifier
22-
@Retention(AnnotationRetention.BINARY)
2320
annotation class IoDispatcher
2421

2522
@Module

app/src/main/java/to/bitkit/ext/DateTime.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package to.bitkit.ext
44

55
import android.icu.text.DateFormat
66
import android.icu.util.ULocale
7+
import kotlinx.datetime.Clock
78
import kotlinx.datetime.LocalDate
89
import kotlinx.datetime.TimeZone
910
import kotlinx.datetime.atStartOfDayIn
@@ -22,6 +23,8 @@ import java.util.Locale
2223
import kotlin.time.Duration.Companion.days
2324
import kotlin.time.Duration.Companion.milliseconds
2425

26+
fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMilliseconds()
27+
2528
fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS)
2629

2730
fun Instant.formatted(pattern: String = DatePattern.DATE_TIME): String {

app/src/main/java/to/bitkit/models/BackupCategory.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ enum class BackupCategory(
5757
@Serializable
5858
data class BackupItemStatus(
5959
val running: Boolean = false,
60-
val synced: Long = 0L,
61-
val required: Long = 0L,
62-
)
60+
val synced: Long = 0,
61+
val required: Long = 0,
62+
) {
63+
val isRequired: Boolean get() = synced < required
64+
}

app/src/main/java/to/bitkit/repositories/BackupRepo.kt

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ import to.bitkit.data.WidgetsData
2323
import to.bitkit.data.WidgetsStore
2424
import to.bitkit.data.backup.VssBackupClient
2525
import to.bitkit.data.resetPin
26-
import to.bitkit.di.BgDispatcher
26+
import to.bitkit.di.IoDispatcher
2727
import to.bitkit.di.json
2828
import to.bitkit.ext.formatPlural
29+
import to.bitkit.ext.nowMillis
2930
import to.bitkit.models.ActivityBackupV1
3031
import to.bitkit.models.BackupCategory
3132
import to.bitkit.models.BackupItemStatus
@@ -36,14 +37,15 @@ import to.bitkit.models.WalletBackupV1
3637
import to.bitkit.services.LightningService
3738
import to.bitkit.ui.shared.toast.ToastEventBus
3839
import to.bitkit.utils.Logger
40+
import to.bitkit.utils.jsonLogOf
3941
import javax.inject.Inject
4042
import javax.inject.Singleton
4143

4244
@Suppress("LongParameterList")
4345
@Singleton
4446
class BackupRepo @Inject constructor(
4547
@ApplicationContext private val context: Context,
46-
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
48+
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
4749
private val cacheStore: CacheStore,
4850
private val vssBackupClient: VssBackupClient,
4951
private val settingsStore: SettingsStore,
@@ -54,7 +56,7 @@ class BackupRepo @Inject constructor(
5456
private val clock: Clock,
5557
private val db: AppDb,
5658
) {
57-
private val scope = CoroutineScope(bgDispatcher + SupervisorJob())
59+
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
5860

5961
private val backupJobs = mutableMapOf<BackupCategory, Job>()
6062
private val statusObserverJobs = mutableListOf<Job>()
@@ -65,6 +67,11 @@ class BackupRepo @Inject constructor(
6567

6668
private var lastNotificationTime = 0L
6769

70+
fun reset() {
71+
stopObservingBackups()
72+
vssBackupClient.reset()
73+
}
74+
6875
fun startObservingBackups() {
6976
if (isObserving) return
7077

@@ -112,7 +119,7 @@ class BackupRepo @Inject constructor(
112119
old.synced == new.synced && old.required == new.required
113120
}
114121
.collect { status ->
115-
if (status.synced < status.required && !status.running && !isRestoring) {
122+
if (status.isRequired && !status.running && !isRestoring) {
116123
scheduleBackup(category)
117124
}
118125
}
@@ -249,12 +256,22 @@ class BackupRepo @Inject constructor(
249256
Logger.verbose("Scheduling backup for: '$category'", context = TAG)
250257

251258
backupJobs[category] = scope.launch {
259+
// Set running immediately to prevent UI showing failure during debounce
260+
cacheStore.updateBackupStatus(category) {
261+
it.copy(running = true)
262+
}
263+
252264
delay(BACKUP_DEBOUNCE)
253265

254266
// Double-check if backup is still needed
255267
val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus()
256-
if (status.synced < status.required && !status.running && !isRestoring) {
268+
if (status.isRequired && !isRestoring) {
257269
triggerBackup(category)
270+
} else {
271+
// Backup no longer needed, reset running flag
272+
cacheStore.updateBackupStatus(category) {
273+
it.copy(running = false)
274+
}
258275
}
259276
}
260277
}
@@ -268,7 +285,7 @@ class BackupRepo @Inject constructor(
268285
val hasFailedBackups = BackupCategory.entries.any { category ->
269286
val status = backupStatuses[category] ?: BackupItemStatus()
270287

271-
val isPendingAndOverdue = status.synced < status.required &&
288+
val isPendingAndOverdue = status.isRequired &&
272289
currentTime - status.required > FAILED_BACKUP_CHECK_TIME
273290
return@any isPendingAndOverdue
274291
}
@@ -290,13 +307,13 @@ class BackupRepo @Inject constructor(
290307
type = Toast.ToastType.ERROR,
291308
title = context.getString(R.string.settings__backup__failed_title),
292309
description = context.getString(R.string.settings__backup__failed_message).formatPlural(
293-
mapOf("interval" to (BACKUP_CHECK_INTERVAL / 60000)) // displayed in minutes
310+
mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS))
294311
),
295312
)
296313
}
297314
}
298315

299-
suspend fun triggerBackup(category: BackupCategory) = withContext(bgDispatcher) {
316+
suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) {
300317
Logger.debug("Backup starting for: '$category'", context = TAG)
301318

302319
cacheStore.updateBackupStatus(category) {
@@ -385,12 +402,23 @@ class BackupRepo @Inject constructor(
385402
BackupCategory.LIGHTNING_CONNECTIONS -> throw NotImplementedError("LIGHTNING backup is managed by ldk-node")
386403
}
387404

388-
suspend fun performFullRestoreFromLatestBackup(): Result<Unit> = withContext(bgDispatcher) {
405+
suspend fun performFullRestoreFromLatestBackup(
406+
onCacheRestored: suspend () -> Unit = {},
407+
): Result<Unit> = withContext(ioDispatcher) {
389408
Logger.debug("Full restore starting", context = TAG)
390409

391410
isRestoring = true
392411

393412
return@withContext try {
413+
performRestore(BackupCategory.METADATA) { dataBytes ->
414+
val parsed = json.decodeFromString<MetadataBackupV1>(String(dataBytes))
415+
cacheStore.update { parsed.cache }
416+
Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG)
417+
onCacheRestored()
418+
db.tagMetadataDao().upsert(parsed.tagMetadata)
419+
Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG)
420+
}
421+
394422
performRestore(BackupCategory.SETTINGS) { dataBytes ->
395423
val parsed = json.decodeFromString<SettingsData>(String(dataBytes)).resetPin()
396424
settingsStore.update { parsed }
@@ -404,12 +432,6 @@ class BackupRepo @Inject constructor(
404432
db.transferDao().upsert(parsed.transfers)
405433
Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG)
406434
}
407-
performRestore(BackupCategory.METADATA) { dataBytes ->
408-
val parsed = json.decodeFromString<MetadataBackupV1>(String(dataBytes))
409-
db.tagMetadataDao().upsert(parsed.tagMetadata)
410-
cacheStore.update { parsed.cache }
411-
Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG)
412-
}
413435
performRestore(BackupCategory.BLOCKTANK) { dataBytes ->
414436
val parsed = json.decodeFromString<BlocktankBackupV1>(String(dataBytes))
415437
blocktankRepo.restoreFromBackup(parsed).onSuccess {
@@ -436,6 +458,15 @@ class BackupRepo @Inject constructor(
436458
}
437459
}
438460

461+
fun scheduleFullBackup() {
462+
Logger.debug("Scheduling backups for all categories", context = TAG)
463+
BackupCategory.entries
464+
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }
465+
.forEach {
466+
scheduleBackup(it)
467+
}
468+
}
469+
439470
private suspend fun performRestore(
440471
category: BackupCategory,
441472
restoreAction: suspend (ByteArray) -> Unit,
@@ -453,16 +484,18 @@ class BackupRepo @Inject constructor(
453484
Logger.debug("Restore error for: '$category'", context = TAG)
454485
}
455486

487+
val now = currentTimeMillis()
456488
cacheStore.updateBackupStatus(category) {
457-
it.copy(running = false, synced = currentTimeMillis())
489+
it.copy(running = false, synced = now, required = now)
458490
}
459491
}
460492

461-
private fun currentTimeMillis(): Long = clock.now().toEpochMilliseconds()
493+
private fun currentTimeMillis(): Long = nowMillis(clock)
462494

463495
companion object {
464496
private const val TAG = "BackupRepo"
465497

498+
private const val MINUTE_IN_MS = 60_000
466499
private const val BACKUP_DEBOUNCE = 5000L // 5 seconds
467500
private const val BACKUP_CHECK_INTERVAL = 60 * 1000L // 1 minute
468501
private const val FAILED_BACKUP_CHECK_TIME = 30 * 60 * 1000L // 30 minutes

app/src/main/java/to/bitkit/repositories/HealthRepo.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class HealthRepo @Inject constructor(
126126
val now = clock.now().toEpochMilliseconds()
127127

128128
fun isSyncOk(synced: Long, required: Long) =
129-
synced > required || (now - required) < 5.minutes.inWholeMilliseconds
129+
synced >= required || (now - required) < 5.minutes.inWholeMilliseconds
130130

131131
val isBackupSyncOk = BackupCategory.entries
132132
.filter { it != BackupCategory.LIGHTNING_CONNECTIONS }

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class WalletRepo @Inject constructor(
5252
private val cacheStore: CacheStore,
5353
private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase,
5454
private val vssStoreIdProvider: VssStoreIdProvider,
55+
private val backupRepo: BackupRepo,
5556
) {
5657
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())
5758

@@ -61,11 +62,6 @@ class WalletRepo @Inject constructor(
6162
private val _balanceState = MutableStateFlow(BalanceState())
6263
val balanceState = _balanceState.asStateFlow()
6364

64-
init {
65-
// Load from cache once on init
66-
loadFromCache()
67-
}
68-
6965
fun loadFromCache() {
7066
// TODO try keeping in sync with cache if performant and reliable
7167
repoScope.launch {
@@ -243,15 +239,17 @@ class WalletRepo @Inject constructor(
243239

244240
suspend fun wipeWallet(walletIndex: Int = 0): Result<Unit> = withContext(bgDispatcher) {
245241
try {
242+
backupRepo.reset()
243+
244+
_walletState.update { WalletState() }
245+
_balanceState.update { BalanceState() }
246+
246247
keychain.wipe()
247-
vssStoreIdProvider.clearCache(walletIndex)
248248
db.clearAllTables()
249249
settingsStore.reset()
250250
cacheStore.reset()
251251
// TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities
252252
coreService.activity.removeAll()
253-
_walletState.update { WalletState() }
254-
_balanceState.update { BalanceState() }
255253
setWalletExistsState()
256254

257255
return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex)

app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ private fun BackupSettingsScreenContent(
9191
onBack: () -> Unit,
9292
onClose: () -> Unit,
9393
) {
94-
val allSynced = uiState.categories.all { it.status.synced >= it.status.required }
94+
val allSynced = uiState.categories.all { !it.status.isRequired }
9595
ScreenColumn {
9696
AppTopBar(
9797
titleText = stringResource(R.string.settings__backup__title),
@@ -158,7 +158,7 @@ private fun BackupStatusItem(
158158

159159
val subtitle = when {
160160
status.running -> "Running" // TODO add missing localized text
161-
status.synced >= status.required -> stringResource(R.string.settings__backup__status_success)
161+
!status.isRequired -> stringResource(R.string.settings__backup__status_success)
162162
.replace("{time}", status.synced.toLocalizedTimestamp())
163163

164164
else -> stringResource(R.string.settings__backup__status_failed)
@@ -182,7 +182,7 @@ private fun BackupStatusItem(
182182
CaptionB(text = subtitle, color = Colors.White64, maxLines = 1)
183183
}
184184

185-
val showRetry = !uiState.disableRetry && !status.running && status.synced < status.required
185+
val showRetry = !uiState.disableRetry && !status.running && status.isRequired
186186
if (showRetry) {
187187
BackupRetryButton(
188188
onClick = { onRetryClick(uiState.category) },
@@ -204,7 +204,7 @@ private fun BackupStatusIcon(
204204
.background(
205205
color = when {
206206
status.running -> Colors.Yellow16
207-
status.synced >= status.required -> Colors.Green16
207+
!status.isRequired -> Colors.Green16
208208
else -> Colors.Red16
209209
},
210210
shape = CircleShape
@@ -215,7 +215,7 @@ private fun BackupStatusIcon(
215215
contentDescription = null,
216216
tint = when {
217217
status.running -> Colors.Yellow
218-
status.synced >= status.required -> Colors.Green
218+
!status.isRequired -> Colors.Green
219219
else -> Colors.Red
220220
},
221221
modifier = Modifier.size(16.dp)
@@ -251,7 +251,6 @@ private fun Preview() {
251251
val timestamp = System.currentTimeMillis() - (minutesAgo * 60 * 1000)
252252

253253
when (it.category) {
254-
BackupCategory.LIGHTNING_CONNECTIONS -> it.copy(disableRetry = true)
255254
BackupCategory.WALLET -> it.copy(status = BackupItemStatus(running = true, required = 1))
256255
BackupCategory.METADATA -> it.copy(status = BackupItemStatus(required = 1))
257256
else -> it.copy(status = BackupItemStatus(synced = timestamp, required = timestamp))

0 commit comments

Comments
 (0)