refactor(commons): collapse pager status flows into one PagingStatus snapshot

BackwardRelayPager exposed five independently-updated StateFlows (exhausted,
relayCount, stalledCount, reachedBack, relayProgress) that are all recomputed
together on every page settle. Co-located consumers therefore paid up to five
separate recompositions per settle and could observe a torn read (e.g. an
updated relayCount against a still-stale relayProgress).

Combine them into one atomic PagingStatus snapshot, emitted by a single
publish(), collected once. updateStatus()/recomputeExhausted() merge into that
publish() (exhausted computed inline). loadingMore stays separate: its falling
edge is debounced on its own timer in PerRelayLoadTracker, decoupled from the
status recompute, so folding it in would miss that delayed transition.

Threaded through the 3 history managers and the 3 feed consumers
(ChatroomListFeedView, ChatroomView, LoadingReplyNote): 12 collectors -> 4 at
the heaviest views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-09 14:13:26 -04:00
co-authored by Claude Opus 4.8
parent b21aecdfca
commit 75315095ee
8 changed files with 154 additions and 196 deletions
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59G
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -64,11 +64,7 @@ class AccountGiftWrapsHistoryEoseManager(
private val pager = BackwardRelayPager("giftwrap.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val exhausted: StateFlow<Boolean> = pager.exhausted
val relayCount: StateFlow<Int> = pager.relayCount
val stalledCount: StateFlow<Int> = pager.stalledCount
val reachedBack: StateFlow<Long?> = pager.reachedBack
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = pager.relayProgress
val status: StateFlow<PagingStatus> = pager.status
private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY
@@ -49,13 +49,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryRelayDialog
import com.vitorpamplona.amethyst.commons.ui.feeds.historySubtitle
import com.vitorpamplona.amethyst.commons.ui.feeds.incompleteSubtitle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -102,30 +101,10 @@ fun LoadingReplyNote(
DmReplyProtocol.NIP17 -> giftWrapsHistory.loadingMore
DmReplyProtocol.NIP04 -> nip04History.loadingMore
}
val exhaustedFlow: StateFlow<Boolean> =
val statusFlow: StateFlow<PagingStatus> =
when (protocol) {
DmReplyProtocol.NIP17 -> giftWrapsHistory.exhausted
DmReplyProtocol.NIP04 -> nip04History.exhausted
}
val relayCountFlow: StateFlow<Int> =
when (protocol) {
DmReplyProtocol.NIP17 -> giftWrapsHistory.relayCount
DmReplyProtocol.NIP04 -> nip04History.relayCount
}
val stalledCountFlow: StateFlow<Int> =
when (protocol) {
DmReplyProtocol.NIP17 -> giftWrapsHistory.stalledCount
DmReplyProtocol.NIP04 -> nip04History.stalledCount
}
val reachedBackFlow: StateFlow<Long?> =
when (protocol) {
DmReplyProtocol.NIP17 -> giftWrapsHistory.reachedBack
DmReplyProtocol.NIP04 -> nip04History.reachedBack
}
val relayProgressFlow: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> =
when (protocol) {
DmReplyProtocol.NIP17 -> giftWrapsHistory.relayProgress
DmReplyProtocol.NIP04 -> nip04History.relayProgress
DmReplyProtocol.NIP17 -> giftWrapsHistory.status
DmReplyProtocol.NIP04 -> nip04History.status
}
val protocolTag =
when (protocol) {
@@ -133,17 +112,19 @@ fun LoadingReplyNote(
DmReplyProtocol.NIP04 -> "NIP-04"
}
val exhausted by exhaustedFlow.collectAsStateWithLifecycle()
val relayCount by relayCountFlow.collectAsStateWithLifecycle()
val stalledCount by stalledCountFlow.collectAsStateWithLifecycle()
val reachedBack by reachedBackFlow.collectAsStateWithLifecycle()
val relayProgress by relayProgressFlow.collectAsStateWithLifecycle()
// One snapshot collector instead of five; the fields below are plain reads off it (downstream unchanged).
val status by statusFlow.collectAsStateWithLifecycle()
val exhausted = status.exhausted
val relayCount = status.relayCount
val stalledCount = status.stalledCount
val reachedBack = status.reachedBack
val relayProgress = status.relayProgress
LaunchedEffect(protocol, loadingFlow, exhaustedFlow) {
LaunchedEffect(protocol, loadingFlow, statusFlow) {
// Step the next, older page whenever the previous one has settled and history isn't exhausted.
// The target may surface mid-page (this composable then leaves composition and cancels us); if
// not, we keep walking until the protocol bottoms out and the filter stops passing.
combine(loadingFlow, exhaustedFlow) { loading, exhaustedNow -> !loading && !exhaustedNow }
combine(loadingFlow, statusFlow) { loading, s -> !loading && !s.exhausted }
.distinctUntilChanged()
.filter { it }
.collect {
@@ -181,7 +181,7 @@ private fun BootstrapHistoryWhenEmpty(
LaunchedEffect(needsBootstrap, giftWrapsHistory) {
if (!needsBootstrap) return@LaunchedEffect
delay(BOOTSTRAP_DEBOUNCE_MS)
combine(giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { loading, exhausted -> !loading && !exhausted }
combine(giftWrapsHistory.loadingMore, giftWrapsHistory.status) { loading, s -> !loading && !s.exhausted }
.distinctUntilChanged()
.filter { it }
.collect { giftWrapsHistory.advanceAll() }
@@ -189,7 +189,7 @@ private fun BootstrapHistoryWhenEmpty(
LaunchedEffect(needsBootstrap, nip04History) {
if (!needsBootstrap) return@LaunchedEffect
delay(BOOTSTRAP_DEBOUNCE_MS)
combine(nip04History.loadingMore, nip04History.exhausted) { loading, exhausted -> !loading && !exhausted }
combine(nip04History.loadingMore, nip04History.status) { loading, s -> !loading && !s.exhausted }
.distinctUntilChanged()
.filter { it }
.collect { nip04History.advanceAll() }
@@ -219,31 +219,25 @@ fun ChatroomViewUI(
val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History }
val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle()
val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle()
val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle()
val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle()
val giftWrapsRelays by giftWrapsHistory.relayCount.collectAsStateWithLifecycle()
val giftWrapsStalled by giftWrapsHistory.stalledCount.collectAsStateWithLifecycle()
val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle()
val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle()
val nip04Stalled by nip04History.stalledCount.collectAsStateWithLifecycle()
val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle()
val nip04Progress by nip04History.relayProgress.collectAsStateWithLifecycle()
val giftWrapsProgress by giftWrapsHistory.relayProgress.collectAsStateWithLifecycle()
// One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six
// separate collectors — the status card and the per-relay markers read all of it together anyway.
val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle()
val nip04Status by nip04History.status.collectAsStateWithLifecycle()
val user = accountViewModel.userProfile()
// Both protocols' per-relay window limits, each carrying the advance() that pulls its own next page.
// Placed in the stream as sentinels (see RelayReachMarkers): a relay pages only while its
// marker is on screen, and keeps paging while it stays there. A protocol drops out once exhausted.
val limits =
remember(nip04Progress, giftWrapsProgress, nip04Exhausted, giftWrapsExhausted, user) {
remember(nip04Status, giftWrapsStatus, user) {
buildList {
if (!giftWrapsExhausted) {
giftWrapsProgress.forEach { (relay, p) ->
if (!giftWrapsStatus.exhausted) {
giftWrapsStatus.relayProgress.forEach { (relay, p) ->
add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) })
}
}
if (!nip04Exhausted) {
nip04Progress.forEach { (relay, p) ->
if (!nip04Status.exhausted) {
nip04Status.relayProgress.forEach { (relay, p) ->
add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) })
}
}
@@ -282,8 +276,8 @@ fun ChatroomViewUI(
// while it pages and crossfades to "All caught up" when that protocol runs dry.
olderBoundary = {
Column {
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate)
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::formatHistoryReachDate)
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate)
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate)
}
},
// Each relay's window-limit marker, placed at its reached cursor (pure UI). Hidden once
@@ -21,12 +21,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -56,11 +56,7 @@ class ChatroomNip04HistorySubAssembler(
private val pager = BackwardRelayPager("convo.nip04.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val exhausted: StateFlow<Boolean> = pager.exhausted
val relayCount: StateFlow<Int> = pager.relayCount
val stalledCount: StateFlow<Int> = pager.stalledCount
val reachedBack: StateFlow<Long?> = pager.reachedBack
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = pager.relayProgress
val status: StateFlow<PagingStatus> = pager.status
override fun user(key: ChatroomQueryState) = key.account.userProfile()
@@ -21,13 +21,13 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -56,11 +56,7 @@ class ChatroomListNip04HistorySubAssembler(
private val pager = BackwardRelayPager("rooms.nip04.history")
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val exhausted: StateFlow<Boolean> = pager.exhausted
val relayCount: StateFlow<Int> = pager.relayCount
val stalledCount: StateFlow<Int> = pager.stalledCount
val reachedBack: StateFlow<Long?> = pager.reachedBack
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = pager.relayProgress
val status: StateFlow<PagingStatus> = pager.status
override fun user(key: ChatroomListState) = key.account.userProfile()
@@ -39,6 +39,7 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
@@ -110,9 +111,9 @@ private fun CrossFadeState(
// not "no conversations" — keep the spinner up rather than flash empty.
val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory }
val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History }
val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle()
val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle()
val historyExhausted = giftWrapsExhausted && nip04Exhausted
val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle()
val nip04Status by nip04History.status.collectAsStateWithLifecycle()
val historyExhausted = giftWrapsStatus.exhausted && nip04Status.exhausted
// A *genuinely* empty list has no rows to host the per-relay window-limit markers, so we step every
// relay one page at a time to hunt for the first rooms. Gated on FeedState.Empty only (never the
@@ -120,8 +121,8 @@ private fun CrossFadeState(
// already loaded does NOT kick a hunt. Once rooms appear the markers take over, demand-driven.
val user = accountViewModel.userProfile()
val bootstrap = feedState is FeedState.Empty
BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { giftWrapsHistory.advanceAll() }
BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.exhausted) { nip04History.advanceAll() }
BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.status) { giftWrapsHistory.advanceAll() }
BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.status) { nip04History.advanceAll() }
CrossfadeIfEnabled(
targetState = feedState,
@@ -167,21 +168,11 @@ private fun FeedLoaded(
val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History }
val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle()
val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle()
val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle()
val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle()
// One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six
// separate collectors — the status card and the per-relay markers read all of it together anyway.
val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle()
val nip04Status by nip04History.status.collectAsStateWithLifecycle()
val user = accountViewModel.userProfile()
// One status card PER protocol, at that protocol's oldest loaded room: it shows what the app is
// reaching for (relays + how far back it has paged) while it loads, then crossfades to "All caught
// up" and collapses when it runs dry.
val giftWrapsRelays by giftWrapsHistory.relayCount.collectAsStateWithLifecycle()
val giftWrapsStalled by giftWrapsHistory.stalledCount.collectAsStateWithLifecycle()
val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle()
val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle()
val nip04Stalled by nip04History.stalledCount.collectAsStateWithLifecycle()
val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle()
val giftWrapsProgress by giftWrapsHistory.relayProgress.collectAsStateWithLifecycle()
val nip04Progress by nip04History.relayProgress.collectAsStateWithLifecycle()
val nip17Name = stringResource(R.string.chats_history_proto_nip17)
val nip04Name = stringResource(R.string.chats_history_proto_nip04)
val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent }
@@ -192,15 +183,15 @@ private fun FeedLoaded(
// marker is on screen and keeps paging while it stays there, so a spam-dense relay never floods —
// you have to scroll through its messages to pull more. A protocol drops out once exhausted.
val limits =
remember(giftWrapsProgress, nip04Progress, giftWrapsExhausted, nip04Exhausted, user) {
remember(giftWrapsStatus, nip04Status, user) {
buildList {
if (!giftWrapsExhausted) {
giftWrapsProgress.forEach { (relay, p) ->
if (!giftWrapsStatus.exhausted) {
giftWrapsStatus.relayProgress.forEach { (relay, p) ->
add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) })
}
}
if (!nip04Exhausted) {
nip04Progress.forEach { (relay, p) ->
if (!nip04Status.exhausted) {
nip04Status.relayProgress.forEach { (relay, p) ->
add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) })
}
}
@@ -242,10 +233,10 @@ private fun FeedLoaded(
// Rendered unconditionally at the protocol's oldest room so the card can run its own
// "All caught up" crossfade-and-collapse when that protocol exhausts.
if (index == oldestNip17Index) {
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate)
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate)
}
if (index == oldestNip04Index) {
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::formatHistoryReachDate)
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate)
}
// Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room:
@@ -272,13 +263,13 @@ private fun FeedLoaded(
private fun BootstrapHistoryWhenEmpty(
active: Boolean,
loadingMore: StateFlow<Boolean>,
exhausted: StateFlow<Boolean>,
status: StateFlow<PagingStatus>,
advanceAll: () -> Unit,
) {
LaunchedEffect(active, loadingMore, exhausted) {
LaunchedEffect(active, loadingMore, status) {
if (!active) return@LaunchedEffect
delay(BOOTSTRAP_DEBOUNCE_MS)
combine(loadingMore, exhausted) { loading, exhaustedNow -> !loading && !exhaustedNow }
combine(loadingMore, status) { loading, s -> !loading && !s.exhausted }
.distinctUntilChanged()
.filter { it }
.collect { advanceAll() }
@@ -32,6 +32,26 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* Atomic snapshot of a [BackwardRelayPager]'s display state. Every field is recomputed together in one
* pass ([BackwardRelayPager.status]'s producer), so a consumer collects ONE flow and never sees a torn
* mix (e.g. an updated [relayCount] against a still-stale [relayProgress]) or pays for several separate
* recompositions per page settle. [BackwardRelayPager.loadingMore] is deliberately NOT folded in here: it
* is debounced on its own timer in the load tracker, decoupled from this recompute.
*/
data class PagingStatus(
// Nothing more reachable right now: every relay is done or stalled. See the pager doc — not "caught up".
val exhausted: Boolean = false,
// Relays currently fetching a page (for an "asking N relays" status line).
val relayCount: Int = 0,
// Not-done relays that can't be reached right now (auth CLOSE / unreachable / silent).
val stalledCount: Int = 0,
// Oldest `createdAt` reached across all relays (the deepest cursor), or null before any delivery.
val reachedBack: Long? = null,
// Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders.
val relayProgress: Map<NormalizedRelayUrl, RelayPagingProgress> = emptyMap(),
)
/**
* Reusable **per-relay backward pagination** engine: pages a set of relays back through history,
* **one page at a time, per relay, on demand**, by `until`+`limit` ([RelayLoadingCursors]) with each
@@ -44,8 +64,8 @@ import java.util.concurrent.ConcurrentHashMap
* scope (e.g. the one the user is viewing), so a backgrounded scope produces no callbacks to mis-route.
*
* What it owns (all transient, recomputed on each [bind]): the in-flight + silence tracking
* ([PerRelayLoadTracker]), the stalled-relay set, and the display [StateFlow]s ([relayProgress],
* [exhausted], [reachedBack], [relayCount], [stalledCount]). The persistent cursors and the pinned
* ([PerRelayLoadTracker]), the stalled-relay set, and the display flows one atomic [status] snapshot
* ([PagingStatus]) plus the separately-debounced [loadingMore]. The persistent cursors and the pinned
* history floor live on the bound [RelayLoadingCursors].
*
* What it does NOT own (the caller supplies these they are protocol- and framework-specific):
@@ -92,33 +112,18 @@ class BackwardRelayPager(
// and recomputed on each [bind]; a stalled relay is kept (its sub stays open) and retried on advance.
private val stalledRelays = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
/** True while any relay is mid-page. Starts false (an idle engine isn't "loading"). */
/**
* True while any relay is mid-page. Starts false (an idle engine isn't "loading"). Kept apart from
* [status] on purpose: the load tracker debounces this flow's falling edge on its own timer, decoupled
* from the [publishStatus] recompute, so folding it into the snapshot would miss that delayed flip.
*/
val loadingMore: StateFlow<Boolean> = loadTracker.loading
private val _exhausted = MutableStateFlow(false)
private val _status = MutableStateFlow(PagingStatus())
/** Nothing more reachable right now: every relay is done or stalled. See class doc — not "caught up". */
val exhausted: StateFlow<Boolean> = _exhausted.asStateFlow()
private val _relayCount = MutableStateFlow(0)
/** Relays currently fetching a page (for an "asking N relays" status line). */
val relayCount: StateFlow<Int> = _relayCount.asStateFlow()
private val _stalledCount = MutableStateFlow(0)
/** Not-done relays that can't be reached right now (auth CLOSE / unreachable / silent). */
val stalledCount: StateFlow<Int> = _stalledCount.asStateFlow()
private val _reachedBack = MutableStateFlow<Long?>(null)
/** Oldest `createdAt` reached across all relays (the deepest cursor), or null before any delivery. */
val reachedBack: StateFlow<Long?> = _reachedBack.asStateFlow()
private val _relayProgress = MutableStateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>>(emptyMap())
/** Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders. */
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = _relayProgress.asStateFlow()
/** One atomic snapshot of the display state (exhausted / counts / reached / per-relay progress), all
* recomputed together in [publishStatus] so consumers collect ONE flow and never see a torn mix. */
val status: StateFlow<PagingStatus> = _status.asStateFlow()
// The session-pinned floor for the active scope — kept on its cursors so it persists with the scope
// and does not drift forward on recompute (which would re-trigger an undelivered relay's loader).
@@ -144,8 +149,7 @@ class BackwardRelayPager(
loadTracker.bind(scope)
loadTracker.reset()
stalledRelays.clear()
updateStatus()
recomputeExhausted()
publishStatus()
}
/**
@@ -170,8 +174,7 @@ class BackwardRelayPager(
/** Steps a single [relay] to its next, older page. @return true if it actually advanced. */
fun advance(relay: NormalizedRelayUrl): Boolean {
if (!arm(relay)) return false
_exhausted.value = false
updateStatus()
publishStatus()
return true
}
@@ -180,10 +183,7 @@ class BackwardRelayPager(
val relays = relaysFor() ?: return false
var any = false
relays.forEach { if (arm(it)) any = true }
if (any) {
_exhausted.value = false
updateStatus()
}
if (any) publishStatus()
return any
}
@@ -219,8 +219,7 @@ class BackwardRelayPager(
c.onEose(relay)
loadTracker.onSettled(relay)
val done = c.isDone(relay)
updateStatus()
recomputeExhausted()
publishStatus()
return done
}
@@ -231,8 +230,7 @@ class BackwardRelayPager(
) {
loadTracker.onSettled(relay)
markStalled(relay, "CLOSED: $message")
updateStatus()
recomputeExhausted()
publishStatus()
}
/** [relay] is unreachable right now: settle it and flag it stalled (kept, retryable). */
@@ -242,16 +240,14 @@ class BackwardRelayPager(
) {
loadTracker.onSettled(relay)
markStalled(relay, "cannot connect: $message")
updateStatus()
recomputeExhausted()
publishStatus()
}
// The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag them
// stalled but kept, so the window can settle instead of hanging on a dead relay.
private fun onSilenced(relays: Set<NormalizedRelayUrl>) {
relays.forEach { markStalled(it, "no response (silence timeout)") }
updateStatus()
recomputeExhausted()
publishStatus()
}
private fun markStalled(
@@ -261,40 +257,48 @@ class BackwardRelayPager(
if (stalledRelays.add(relay)) Log.d(TAG) { "[$name] ${relay.url} stalled — $reason (kept, advance to retry)" }
}
// --- Display-flow recompute (from the bound cursors). ---
// --- Display-state recompute (one atomic snapshot from the bound cursors). ---
/** Recomputes the display flows from the active scope's cursors. */
fun updateStatus() {
/**
* Recomputes the whole [status] snapshot from the active scope's cursors and publishes it in one
* emission, so consumers never see a torn mix of fields nor pay for several recompositions per settle.
*
* `exhausted` is computed here too: nothing more is reachable once every relay is done (empty page) or
* stalled (unreachable) a merely parked relay (more to load, just not advancing) keeps it false. An
* empty / unbound scope leaves `exhausted` at its previous value (mirrors the old recompute's
* early-return), so a transient empty relay set never flips it spuriously.
*/
private fun publishStatus() {
val c = cursors
val relays = relaysFor() ?: emptySet()
_relayCount.value = loadTracker.count()
val floor = floor()
_reachedBack.value = c?.deepestReached(relays, floor)
_stalledCount.value = relays.count { it in stalledRelays && c?.isDone(it) != true }
_relayProgress.value =
relays.associateWith { relay ->
RelayPagingProgress(
reachedUntil = c?.reachedUntilFor(relay, floor) ?: floor,
done = c?.isDone(relay) ?: false,
stalled = relay in stalledRelays && c?.isDone(relay) != true,
)
val prev = _status.value
val exhausted =
if (c == null || relays.isEmpty()) {
prev.exhausted
} else {
relays.none { !c.isDone(it) && it !in stalledRelays }
}
}
// Exhausted once every relay is either done (empty page) or stalled (unreachable) — nothing more is
// reachable right now. A merely parked relay (more to load, just not advancing) keeps this false.
private fun recomputeExhausted() {
val c = cursors ?: return
val relays = relaysFor() ?: return
if (relays.isEmpty()) return
val pending = relays.any { !c.isDone(it) && it !in stalledRelays }
val ex = !pending
if (ex && !_exhausted.value) {
if (exhausted && !prev.exhausted && c != null) {
val done = relays.filter { c.isDone(it) }.map { it.url }
val stuck = relays.filter { it in stalledRelays && !c.isDone(it) }.map { it.url }
Log.d(TAG) { "[$name] window settled (nothing more reachable) — done=$done stalled=$stuck" }
}
_exhausted.value = ex
_status.value =
PagingStatus(
exhausted = exhausted,
relayCount = loadTracker.count(),
stalledCount = relays.count { it in stalledRelays && c?.isDone(it) != true },
reachedBack = c?.deepestReached(relays, floor),
relayProgress =
relays.associateWith { relay ->
RelayPagingProgress(
reachedUntil = c?.reachedUntilFor(relay, floor) ?: floor,
done = c?.isDone(relay) ?: false,
stalled = relay in stalledRelays && c?.isDone(relay) != true,
)
},
)
}
companion object {
@@ -66,7 +66,7 @@ class BackwardRelayPagerTest {
@Test
fun firstPageRequestsTheFloorAndAnEmptyPageIsCaughtUp() {
val (p, cursors) = pagerOf(r1)
assertFalse(p.exhausted.value)
assertFalse(p.status.value.exhausted)
assertTrue(p.advance(r1))
// The very first page asks `until = floor` (pinned on the bound cursors).
@@ -75,12 +75,12 @@ class BackwardRelayPagerTest {
// Empty page + EOSE → that relay is done; the only relay is done → genuinely caught up.
assertTrue(p.onEose(r1))
assertTrue(
p.relayProgress.value
p.status.value.relayProgress
.getValue(r1)
.done,
)
assertTrue(p.exhausted.value)
assertEquals(0, p.stalledCount.value)
assertTrue(p.status.value.exhausted)
assertEquals(0, p.status.value.stalledCount)
}
@Test
@@ -94,12 +94,12 @@ class BackwardRelayPagerTest {
p.onEvent(r1, 90)
assertFalse(p.onEose(r1))
assertFalse(
p.relayProgress.value
p.status.value.relayProgress
.getValue(r1)
.done,
)
assertEquals(80L, p.reachedBack.value)
assertFalse(p.exhausted.value)
assertEquals(80L, p.status.value.reachedBack)
assertFalse(p.status.value.exhausted)
// The next page must start strictly below the oldest reached (80 → until 79).
assertTrue(p.advance(r1))
@@ -107,8 +107,8 @@ class BackwardRelayPagerTest {
// Empty page now → done → caught up.
assertTrue(p.onEose(r1))
assertTrue(p.exhausted.value)
assertEquals(0, p.stalledCount.value)
assertTrue(p.status.value.exhausted)
assertEquals(0, p.status.value.stalledCount)
}
@Test
@@ -119,24 +119,24 @@ class BackwardRelayPagerTest {
// r1 genuinely bottoms out; r2 is still pending, so not exhausted yet.
p.onEose(r1)
assertFalse(p.exhausted.value)
assertFalse(p.status.value.exhausted)
// r2 auth-walls the REQ → stalled (kept, not done).
p.onClosed(r2, "auth-required")
assertTrue(
p.relayProgress.value
p.status.value.relayProgress
.getValue(r2)
.stalled,
)
assertFalse(
p.relayProgress.value
p.status.value.relayProgress
.getValue(r2)
.done,
)
// Every relay is now done-or-stalled → exhausted, but it is INCOMPLETE: one relay unreachable.
assertTrue(p.exhausted.value)
assertEquals(1, p.stalledCount.value)
assertTrue(p.status.value.exhausted)
assertEquals(1, p.status.value.stalledCount)
}
@Test
@@ -145,12 +145,12 @@ class BackwardRelayPagerTest {
p.advance(r1)
p.onCannotConnect(r1, "offline")
assertTrue(
p.relayProgress.value
p.status.value.relayProgress
.getValue(r1)
.stalled,
)
assertTrue(p.exhausted.value)
assertEquals(1, p.stalledCount.value)
assertTrue(p.status.value.exhausted)
assertEquals(1, p.status.value.stalledCount)
}
@Test
@@ -158,18 +158,18 @@ class BackwardRelayPagerTest {
val (p, _) = pagerOf(r1)
p.advance(r1)
p.onClosed(r1, "auth-required")
assertTrue(p.exhausted.value)
assertEquals(1, p.stalledCount.value)
assertTrue(p.status.value.exhausted)
assertEquals(1, p.status.value.stalledCount)
// Retrying it re-arms the relay: no longer stalled, no longer exhausted.
assertTrue(p.advance(r1))
assertFalse(
p.relayProgress.value
p.status.value.relayProgress
.getValue(r1)
.stalled,
)
assertFalse(p.exhausted.value)
assertEquals(0, p.stalledCount.value)
assertFalse(p.status.value.exhausted)
assertEquals(0, p.status.value.stalledCount)
}
@Test
@@ -185,7 +185,7 @@ class BackwardRelayPagerTest {
p.onEose(r2) // r2 reached 300
// Deepest = the oldest point any relay has reached.
assertEquals(300L, p.reachedBack.value)
assertEquals(300L, p.status.value.reachedBack)
}
@Test
@@ -219,25 +219,25 @@ class BackwardRelayPagerTest {
p.advance(r2)
p.onEose(r1)
p.onClosed(r2, "auth-required")
assertTrue(p.exhausted.value)
assertEquals(1, p.stalledCount.value)
assertTrue(p.status.value.exhausted)
assertEquals(1, p.status.value.stalledCount)
// Bind to a fresh scope B: the flows reflect B's own (empty) state — nothing stalled, and its
// reach sits at B's floor (no history fetched yet — markers start at the live-tail boundary).
p.bind(cursorsB, scope) { listOf(r3) }
assertFalse(p.exhausted.value)
assertEquals(0, p.stalledCount.value)
assertEquals(cursorsB.floor, p.reachedBack.value)
assertFalse(p.status.value.exhausted)
assertEquals(0, p.status.value.stalledCount)
assertEquals(cursorsB.floor, p.status.value.reachedBack)
// Rebind to A: r1 is still DONE (its cursor persisted on cursorsA), but r2's stall is gone — stall
// is transient, so r2 is pending again and A is no longer exhausted (it will retry the auth relay).
p.bind(cursorsA, scope) { listOf(r1, r2) }
assertTrue(
p.relayProgress.value
p.status.value.relayProgress
.getValue(r1)
.done,
)
assertEquals(0, p.stalledCount.value)
assertFalse(p.exhausted.value)
assertEquals(0, p.status.value.stalledCount)
assertFalse(p.status.value.exhausted)
}
}