fix: complete DM windows on quiescence, add load-entire-history button

The fixed 15s window-load timeout fired mid-flood on accounts with a large
DM history: a relay streaming thousands of stored gift wraps never EOSE'd
within 15s, so the window was declared "loaded" while events were still
pouring in and before they were decrypted into rooms. The rooms list still
looked empty, so auto-fill widened again — re-issuing an ever-wider REQ that
re-downloaded the whole history, over and over, every 15s.

WindowLoadTracker now completes a window on activity quiescence instead of a
wall clock: it stays loading until every expected relay EOSEs, or the event
stream goes quiet for a few seconds. Every event (stored backfill included)
bumps the idle timer via onActivity, so a relay mid-flood is never mistaken
for a finished window; an absolute cap bounds pathological dribble. Both DM
loaders feed event activity in (the NIP-04 loader now uses a custom listener
so it sees stored events, not just live ones).

Also add a "Load entire history" button to the rooms-list footer: it jumps
the window straight to the max lookback (TimeWindowPagination.loadAll) so a
single REQ pulls everything — the pre-windowing behavior — and marks the
window exhausted so the auto-fill loop stops.
This commit is contained in:
Claude
2026-05-30 22:05:57 +00:00
parent d311a01964
commit 6f9c5bbdf0
7 changed files with 152 additions and 23 deletions
@@ -27,47 +27,73 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Tracks when one relay-subscription "window" has finished loading, so callers can wait for the
* WHOLE set of relays to answer instead of declaring victory on the first EOSE.
* Tracks when one relay-subscription "window" has finished loading, so callers (the rooms-screen
* auto-fill loop) can wait for the WHOLE response instead of declaring victory on the first EOSE.
*
* A subscription fans a single REQ out to several relays. The first EOSE is a misleading "done"
* signal: a fast but near-empty relay can EOSE in milliseconds while the relay that actually holds
* the data is still connecting (or stuck in an auth handshake). An auto-fill loop driven by the
* first EOSE would therefore widen the time window again before the slow relay ever answered,
* walking the window back uselessly.
* the data is still connecting, stuck in an auth handshake, or busy streaming thousands of stored
* events. An auto-fill loop driven by the first EOSE — or by a fixed wall-clock timeout — would
* widen the window again mid-stream, before the events were even decrypted into rooms, re-issuing
* an ever-wider REQ that re-downloads the whole history over and over.
*
* [loading] stays true until EVERY [setExpectedRelays] relay has answered (an EOSE, or a live event
* that implies the stored set already drained), or until [timeout] elapses as a backstop for relays
* that never answer (down, or looping on `auth-required`).
* So completion is **activity-based**: [loading] stays true until either every expected relay has
* EOSE'd, or the event stream has gone quiet for [idleTimeout] (a flood of events keeps resetting
* that timer via [onActivity], so a window that is still streaming is never declared done). An
* [absoluteCap] bounds the wait for pathological relays that dribble forever.
*/
class WindowLoadTracker(
private val timeout: Duration = 15.seconds,
private val idleTimeout: Duration = 3.seconds,
private val absoluteCap: Duration = 5.minutes,
) {
private val _loading = MutableStateFlow(true)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private var expected: Set<NormalizedRelayUrl> = emptySet()
private val responded = mutableSetOf<NormalizedRelayUrl>()
private var timeoutJob: Job? = null
private var watchdog: Job? = null
/** Begins a fresh window load: clears the responded set, raises [loading], and arms the timeout. */
// Wall-clock of the last EOSE or event for the current window; the watchdog completes the
// window once this stops advancing for [idleTimeout]. Volatile so the hot per-event path
// ([onActivity]) stays lock-free.
@Volatile
private var lastActivityMs = 0L
/** Begins a fresh window load: clears the responded set, raises [loading], and arms the watchdog. */
@Synchronized
fun startLoading(scope: CoroutineScope) {
responded.clear()
lastActivityMs = System.currentTimeMillis()
_loading.value = true
timeoutJob?.cancel()
timeoutJob =
watchdog?.cancel()
watchdog =
scope.launch {
delay(timeout)
finish()
val deadline = System.currentTimeMillis() + absoluteCap.inWholeMilliseconds
while (isActive && _loading.value) {
delay(IDLE_CHECK_MS)
val now = System.currentTimeMillis()
if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds || now >= deadline) {
finish()
}
}
}
}
/**
* Records that the current window is still actively receiving events (stored OR live). Keeps the
* idle watchdog from completing while a relay is mid-flood. Lock-free: just bumps a timestamp.
*/
fun onActivity() {
lastActivityMs = System.currentTimeMillis()
}
/** Records which relays the current REQ was sent to. Completes immediately if there are none. */
@Synchronized
fun setExpectedRelays(relays: Set<NormalizedRelayUrl>) {
@@ -78,6 +104,7 @@ class WindowLoadTracker(
/** Marks [relay] as having answered (EOSE or live event). Completes once all expected have. */
@Synchronized
fun onRelayResponded(relay: NormalizedRelayUrl) {
lastActivityMs = System.currentTimeMillis()
responded.add(relay)
if (expected.isNotEmpty() && responded.containsAll(expected)) finish()
}
@@ -85,7 +112,11 @@ class WindowLoadTracker(
@Synchronized
private fun finish() {
_loading.value = false
timeoutJob?.cancel()
timeoutJob = null
watchdog?.cancel()
watchdog = null
}
companion object {
private const val IDLE_CHECK_MS = 500L
}
}
@@ -128,6 +128,20 @@ class AccountGiftWrapsEoseManager(
invalidateFilters()
}
/**
* Jumps the gift-wrap window straight to the maximum lookback so a single REQ pulls the entire
* history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops.
*/
fun loadEverything(user: User) {
val window = windowFor(user)
if (window.isExhausted()) return
window.loadAll()
_exhausted.value = true
Log.d(TAG) { "loadEverything: pubkey=${user.pubkeyHex.take(8)}… loading full history since ${window.since}" }
scope?.let { windowLoad.startLoading(it) }
invalidateFilters()
}
override fun newEose(
key: AccountQueryState,
relay: NormalizedRelayUrl,
@@ -195,6 +209,9 @@ class AccountGiftWrapsEoseManager(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Every event (stored backfill included) keeps the window-load watchdog alive,
// so a relay mid-flood is never mistaken for a finished window.
windowLoad.onActivity()
if (pubkey !in bootEoseLogged) {
bootEventCount[pubkey] = (bootEventCount[pubkey] ?: 0) + 1
}
@@ -26,12 +26,15 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseMa
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
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
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -103,6 +106,19 @@ class DMsFromUserFilterSubAssembler(
invalidateFilters()
}
/**
* Jumps the NIP-04 window straight to the maximum lookback so a single REQ pulls the entire
* history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops.
*/
fun loadEverything(user: User) {
val window = windowFor(user)
if (window.isExhausted()) return
window.loadAll()
_exhausted.value = true
scope?.let { windowLoad.startLoading(it) }
invalidateFilters()
}
override fun newEose(
key: ChatroomListState,
relay: NormalizedRelayUrl,
@@ -137,7 +153,30 @@ class DMsFromUserFilterSubAssembler(
},
)
return super.newSub(key)
// Custom listener (vs super.newSub) so every event — stored backfill included — keeps the
// window-load watchdog alive; otherwise a NIP-04 flood would look "done" mid-stream.
return requestNewSubscription(
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
windowLoad.onActivity()
if (isLive) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
}
},
)
}
override fun endSub(
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -31,13 +31,18 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
@@ -149,6 +154,9 @@ private fun FeedLoaded(
val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle()
val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle()
val loadingMore = loadingGiftWraps || loadingNip04
val exhaustedGiftWraps by giftWraps.exhausted.collectAsStateWithLifecycle()
val exhaustedNip04 by nip04Dms.exhausted.collectAsStateWithLifecycle()
val historyExhausted = exhaustedGiftWraps && exhaustedNip04
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
@@ -171,13 +179,28 @@ private fun FeedLoaded(
)
}
if (loadingMore) {
// Footer: shows the auto-fill / full-load spinner, and — while there is still older history
// to reach — a button to skip the windowed paging and pull the entire history at once.
if (loadingMore || !historyExhausted) {
item(key = "loadingMoreFooter") {
Row(
Column(
Modifier.fillMaxWidth().padding(vertical = Size10dp),
horizontalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(Modifier.size(Size25dp))
if (loadingMore) {
CircularProgressIndicator(Modifier.size(Size25dp))
}
if (!historyExhausted) {
TextButton(
onClick = {
val user = accountViewModel.userProfile()
giftWraps.loadEverything(user)
nip04Dms.loadEverything(user)
},
) {
Text(stringResource(R.string.chats_load_entire_history))
}
}
}
}
}
+1
View File
@@ -274,6 +274,7 @@
<string name="generate_a_new_key">Generate a new key</string>
<string name="loading_feed">Loading feed</string>
<string name="loading_account">Loading account</string>
<string name="chats_load_entire_history">Load entire history</string>
<string name="error_loading_replies">"Error loading replies: "</string>
<string name="try_again">Try again</string>
<string name="notification_feed_is_empty">No notifications yet.</string>
@@ -63,6 +63,11 @@ class TimeWindowPagination(
if (growthFactor > 1L) currentStep *= growthFactor
}
/** Jumps straight to [maxLookback] so a single request pulls the entire history. */
fun loadAll() {
since = floor()
}
/** True once the window has reached [maxLookback] — there is no older history to request. */
fun isExhausted(): Boolean = since <= floor()
@@ -98,6 +98,19 @@ class TimeWindowPaginationTest {
assertTrue("since must not go past the floor", pagination.since >= floor - 2 && pagination.since <= floor + 2)
}
@Test
fun loadAllJumpsStraightToExhaustion() {
val maxLookback = 100L
val pagination = TimeWindowPagination(initialWindow = 10L, step = 10L, growthFactor = 2L, maxLookback = maxLookback)
assertTrue("not exhausted before loadAll", !pagination.isExhausted())
pagination.loadAll()
assertTrue("loadAll exhausts the window in one step", pagination.isExhausted())
val floor = TimeUtils.now() - maxLookback
assertTrue("since lands at the floor", kotlin.math.abs(pagination.since - floor) <= 2)
}
@Test
fun resetClearsStepGrowth() {
val pagination = TimeWindowPagination(initialWindow = 10L, step = 100L, growthFactor = 2L, maxLookback = Long.MAX_VALUE / 2)