From 2374dac2add198f1f64768b41789ef245fab4469 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Thu, 26 Feb 2026 20:49:12 +0300 Subject: [PATCH] fix: prevent staking dashboard infinite loading - Add maxAttempts parameter to retryUntilDone (default unlimited for backward compat), use 5 retries for staking stats fetch - Catch fetchStakingStats failure in dashboard update system and fallback to empty stats instead of hanging forever - Restore stale scope detection in ComputationalCache so cancelled aggregate scopes are recreated instead of returning stale entries --- .../data/memory/RealComputationalCache.kt | 17 +++++++++++------ .../novafoundation/nova/common/utils/Retries.kt | 9 +++++++-- .../network/stats/StakingStatsDataSource.kt | 2 +- .../updaters/StakingDashboardUpdateSystem.kt | 9 ++++++++- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/RealComputationalCache.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/RealComputationalCache.kt index 12f4b11..cbcf632 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/memory/RealComputationalCache.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/RealComputationalCache.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -72,16 +73,20 @@ internal class RealComputationalCache : ComputationalCache, CoroutineScope by Co cachedAction: AwaitableConstructor ): T { val awaitable = mutex.withLock { - if (key in memory) { + val existing = memory[key] + if (existing != null && existing.aggregateScope.isActive) { Log.d(LOG_TAG, "Key $key requested - already present") - val entry = memory.getValue(key) + existing.dependents += scope - entry.dependents += scope - - entry.awaitable + existing.awaitable } else { - Log.d(LOG_TAG, "Key $key requested - creating new operation") + if (existing != null) { + Log.d(LOG_TAG, "Key $key requested - stale (aggregateScope cancelled), recreating") + memory.remove(key) + } else { + Log.d(LOG_TAG, "Key $key requested - creating new operation") + } val aggregateScope = CoroutineScope(Dispatchers.Default) val awaitable = cachedAction(aggregateScope) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Retries.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Retries.kt index 89818bf..ba836ed 100644 --- a/common/src/main/java/io/novafoundation/nova/common/utils/Retries.kt +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Retries.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.delay suspend inline fun retryUntilDone( retryStrategy: ReconnectStrategy = LinearReconnectStrategy(step = 500L), + maxAttempts: Int = Int.MAX_VALUE, block: () -> T, ): T { var attempt = 0 @@ -17,10 +18,14 @@ suspend inline fun retryUntilDone( if (blockResult.isSuccess) { return blockResult.requireValue() } else { - Log.e("RetryUntilDone", "Failed to execute retriable operation:", blockResult.requireException()) - attempt++ + if (attempt >= maxAttempts) { + throw blockResult.requireException() + } + + Log.e("RetryUntilDone", "Failed to execute retriable operation (attempt $attempt):", blockResult.requireException()) + delay(retryStrategy.getTimeForReconnect(attempt)) } } diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingStatsDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingStatsDataSource.kt index 5ee63bb..8b5ee29 100644 --- a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingStatsDataSource.kt +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingStatsDataSource.kt @@ -39,7 +39,7 @@ class RealStakingStatsDataSource( stakingAccounts: StakingAccounts, stakingChains: List ): MultiChainStakingStats = withContext(Dispatchers.IO) { - retryUntilDone { + retryUntilDone(maxAttempts = 5) { val globalConfig = globalConfigDataSource.getGlobalConfig() val chainsByEndpoint = splitChainsByEndpoint(stakingChains, globalConfig) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/StakingDashboardUpdateSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/StakingDashboardUpdateSystem.kt index 8ba1cce..69f8db8 100644 --- a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/StakingDashboardUpdateSystem.kt +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/StakingDashboardUpdateSystem.kt @@ -127,9 +127,16 @@ class RealStakingDashboardUpdateSystem( .onEach { latestOffChainSyncIndex.value = it.index } .throttleLast(offChainSyncDebounceRate) .mapLatest { (index, stakingAccounts) -> + val stats = runCatching { + stakingStatsDataSource.fetchStakingStats(stakingAccounts, stakingChains) + }.getOrElse { + Log.d("StakingDashboardUpdateSystem", "Failed to fetch staking stats after retries", it) + emptyMap() + } + MultiChainOffChainSyncResult( index = index, - multiChainStakingStats = stakingStatsDataSource.fetchStakingStats(stakingAccounts, stakingChains), + multiChainStakingStats = stats, ) } }