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
This commit is contained in:
2026-02-26 20:49:12 +03:00
parent 853bbf567e
commit 2374dac2ad
4 changed files with 27 additions and 10 deletions
@@ -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>
): 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)
@@ -7,6 +7,7 @@ import kotlinx.coroutines.delay
suspend inline fun <T> retryUntilDone(
retryStrategy: ReconnectStrategy = LinearReconnectStrategy(step = 500L),
maxAttempts: Int = Int.MAX_VALUE,
block: () -> T,
): T {
var attempt = 0
@@ -17,10 +18,14 @@ suspend inline fun <T> 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))
}
}
@@ -39,7 +39,7 @@ class RealStakingStatsDataSource(
stakingAccounts: StakingAccounts,
stakingChains: List<Chain>
): MultiChainStakingStats = withContext(Dispatchers.IO) {
retryUntilDone {
retryUntilDone(maxAttempts = 5) {
val globalConfig = globalConfigDataSource.getGlobalConfig()
val chainsByEndpoint = splitChainsByEndpoint(stakingChains, globalConfig)
@@ -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,
)
}
}