refactor(dm): extract per-relay paging primitives to quartz + add BackwardRelayPager

Step 1+2 of generalizing the DM history pagination into a reusable toolkit.

Move the four transport-agnostic primitives out of the amethyst module into
quartz's jvmAndroid source set, new package
`nip01Core.relay.client.paging`: UntilLimitPager, PerRelayLoadTracker,
WindowLoadTracker, RelayPagingProgress. They were already pure Kotlin (no
Android deps); jvmAndroid keeps their java.util.concurrent / @Synchronized
concurrency without a KMP-atomics rewrite, while making them visible to
amethyst, desktop, and quartz's jvmAndroidTest (geode in-process relay) for
the integration tests to come.

Add BackwardRelayPager<K>: the generic per-relay backward-pagination engine
that collapses the ~80%-identical pager+tracker+status+exhausted bookkeeping
the three DM history loaders each reimplement. It owns the cursors, in-flight
+ silence tracking, stalled set, pinned floor, and the display StateFlows
(relayProgress / exhausted / reachedBack / relayCount / stalledCount); the
caller supplies only the filter builder, the subscription wiring, and a
relaysFor(key) lookup. Not yet wired into the managers — that swap is step 3.

Pure relocation + new component; no behavior change. UntilLimitPagerTest and
WindowLoadTrackerSilenceTest stay in amethyst (they use JUnit) with explicit
imports added, and both still pass against the relocated classes.
This commit is contained in:
Claude
2026-06-05 14:09:12 +00:00
parent 0e4d2e96bc
commit 7ef6224c19
17 changed files with 356 additions and 23 deletions
@@ -24,11 +24,11 @@ import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToP
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.trackingListener
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.Log
@@ -24,15 +24,15 @@ import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToP
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerRelayLoadTracker
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager
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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.PerRelayLoadTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager
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
@@ -60,7 +60,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
@@ -49,9 +49,9 @@ 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.service.relayClient.eoseManagers.RelayPagingProgress
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
@@ -41,7 +41,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
@@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
@@ -21,15 +21,15 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerRelayLoadTracker
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager
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.paging.PerRelayLoadTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager
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
@@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.trackingListener
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.Log
@@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerRelayLoadTracker
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager
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.paging.PerRelayLoadTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager
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
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.trackingListener
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.Log
@@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
@@ -60,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -0,0 +1,331 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.paging
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* 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` ([UntilLimitPager]) — with each
* relay advancing independently the moment *it* settles, never paced by the slowest one. This is the
* generic core extracted from the DM history loaders (gift-wrap, conversation NIP-04, rooms-list
* NIP-04), which were ~80% identical; any feed that wants demand-driven, gap-proof, per-relay history
* paging can build one of these instead of re-deriving the cursor/stall/exhausted bookkeeping.
*
* What it owns: the per-relay cursors ([UntilLimitPager]), the in-flight + silence tracking
* ([PerRelayLoadTracker]), the stalled-relay set, the per-key "exhausted" memo, the session-pinned
* history floor, and the display [StateFlow]s ([relayProgress], [exhausted], [reachedBack],
* [relayCount], [stalledCount]).
*
* What it does NOT own (the caller supplies these — they are protocol- and framework-specific):
* - **Building the actual REQ filters.** The caller reads [armedRelays] + [requestedUntilFor] and
* assembles its own `RelayBasedFilter`s (the kinds / authors / `#p` tags differ per feed).
* - **The subscription lifecycle.** The caller wires its `INostrClient` subscription and forwards
* relay callbacks here via [onEvent] / [onEose] / [onClosed] / [onCannotConnect], then re-issues
* its filter (e.g. `invalidateFilters()`) after [advance] / [advanceAll] return true.
* - **Which relays a key fans out to.** Supplied once as [relaysFor]; the engine reads it whenever it
* needs the active key's relay set (status recompute, exhaustion, membership checks).
*
* ### Keying ([K])
* State is partitioned by an opaque key [K] — e.g. an account pubkey, or `(account, conversation)` —
* so several independently-paged scopes can share one engine without leaking cursors across them, and
* switching the on-screen scope just repoints the display flows ([activate]) instead of resetting
* progress. Exactly one key is "active" (its state is mirrored into the display flows) at a time.
*
* ### Done vs stalled (read [exhausted] with care)
* A relay is **done** once it answers an empty page (gap-proof: nothing older). A relay that won't
* answer right now — auth CLOSE, unreachable, or silent past the tracker's window — is flagged
* **stalled** but kept (its subscription stays open; re-[advance] retries it). [exhausted] flips true
* once every relay is *done or stalled* — "nothing more reachable right now", which is NOT the same as
* "fully caught up". Callers that render a terminal state should split on [stalledCount]: `exhausted &&
* stalledCount == 0` is genuinely caught up; `exhausted && stalledCount > 0` stopped early and may be
* missing messages.
*
* Not internally synchronized beyond the primitives it composes; intended to be driven from one owning
* scope with relay callbacks serialized per relay (as the relay IO layer delivers them).
*/
class BackwardRelayPager<K>(
// Short label for the DMPagination logs (e.g. "giftwrap.history", "convo.nip04.history").
private val name: String,
// Asked of every relay per page; large on purpose (a whole band in one page), and caps per-request
// volume. A relay returning fewer is its own cap, NOT exhaustion — only an empty page ends a relay.
val pageLimit: Int = DEFAULT_PAGE_LIMIT,
// How far below "now" the history floor sits — paging starts here and walks backward. Defaults to
// the one-week live-tail boundary: everything newer is the always-on tail's job.
private val liveTailSeconds: Long = DEFAULT_LIVE_TAIL_SECONDS,
// The relay set a key currently fans out to. Read on every status/exhaustion recompute, so it must
// reflect the key's live relay list. Null/empty means "no relays known yet" (no-op).
private val relaysFor: (K) -> Collection<NormalizedRelayUrl>?,
) {
private val pager = UntilLimitPager<K>()
private val loadTracker = PerRelayLoadTracker(name, onSilenced = ::onSilenced)
// Relays not currently advancing for a key (auth CLOSE / unreachable / silent). Kept (not given up)
// and surfaced as stalled; they resume if the key re-advances them.
private val stalledRelays = ConcurrentHashMap<K, MutableSet<NormalizedRelayUrl>>()
// Per-key exhausted memo, so a backgrounded key keeps its terminal state and switching back to it
// restores the right flag instead of flashing "loading".
private val exhaustedByKey = ConcurrentHashMap<K, Boolean>()
// History starts just below the live-tail floor and pages backward. Pinned per key for the session:
// it must NOT drift forward on every recompute, or an un-delivered relay's marker (which sits at this
// floor) would keep changing and re-trigger its on-screen sentinel.
private val pinnedFloor = ConcurrentHashMap<K, Long>()
// The key whose state is currently mirrored into the display flows (the one on screen). A background
// key's late EOSE still advances its cursors in [pager] but must not overwrite the display flows.
@Volatile
private var activeKey: K? = null
/** True while any relay is mid-page. Starts false (an idle engine isn't "loading"). */
val loadingMore: StateFlow<Boolean> = loadTracker.loading
private val _exhausted = MutableStateFlow(false)
/** 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) — the data on-screen reach markers render. */
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = _relayProgress.asStateFlow()
/** The session-pinned floor for [key] — where its paging starts (just below the live tail). */
fun floorFor(key: K): Long = pinnedFloor.getOrPut(key) { TimeUtils.now() - liveTailSeconds }
// --- Filter building support: the caller assembles the actual REQ from these. ---
/** Relays of [key] that have been advanced (armed) and aren't done — i.e. that should carry a REQ. */
fun armedRelays(
key: K,
relays: Collection<NormalizedRelayUrl>,
): List<NormalizedRelayUrl> = pager.armedRelays(key, relays)
/** The `until` [relay]'s next page should carry for [key] (null if it isn't armed). */
fun requestedUntilFor(
key: K,
relay: NormalizedRelayUrl,
): Long? = pager.requestedUntilFor(key, relay)
// --- Demand-driven advance (the caller re-issues its filter when these return true). ---
/** Steps a single [relay] to its next, older page for [key]. @return true if it actually advanced. */
fun advance(
key: K,
relay: NormalizedRelayUrl,
scope: CoroutineScope,
): Boolean {
if (!arm(key, relay, scope)) return false
if (activeKey == key) _exhausted.value = false
updateStatus(key)
return true
}
/** Steps every not-done, not-in-flight relay of [key] one page. For a scope too small to scroll. */
fun advanceAll(
key: K,
scope: CoroutineScope,
): Boolean {
val relays = relaysFor(key) ?: return false
var any = false
relays.forEach { if (arm(key, it, scope)) any = true }
if (any) {
if (activeKey == key) _exhausted.value = false
updateStatus(key)
}
return any
}
// Moves one relay's cursor to its next page and marks it in-flight. Returns false if it can't advance
// (unknown relay, already fetching, or already done). Does NOT recompute status — the caller batches.
private fun arm(
key: K,
relay: NormalizedRelayUrl,
scope: CoroutineScope,
): Boolean {
val relays = relaysFor(key) ?: return false
if (relay !in relays) return false
if (loadTracker.isInFlight(relay)) return false
if (!pager.advance(key, relay, floorFor(key))) return false
stalledRelays[key]?.remove(relay)
loadTracker.bind(scope)
loadTracker.onAdvance(relay)
return true
}
// --- Subscription callbacks: the owner forwards these from its SubscriptionListener. ---
/** Records one delivered event for [relay] (a sign of life + a page tally entry). */
fun onEvent(
key: K,
relay: NormalizedRelayUrl,
createdAt: Long,
) {
loadTracker.onActivity()
pager.onEvent(key, relay, createdAt)
stalledRelays[key]?.remove(relay)
}
/** Finalizes [relay]'s page on EOSE. @return true if this EOSE is the one that marked it done. */
fun onEose(
key: K,
relay: NormalizedRelayUrl,
): Boolean {
stalledRelays[key]?.remove(relay)
pager.onEose(key, relay)
loadTracker.onSettled(relay)
val done = pager.isDone(key, relay)
updateStatus(key)
recomputeExhausted(key)
return done
}
/** [relay] rejected the REQ (e.g. auth-required): settle it and flag it stalled (kept, retryable). */
fun onClosed(
key: K,
relay: NormalizedRelayUrl,
message: String,
) {
loadTracker.onSettled(relay)
markStalled(key, relay, "CLOSED: $message")
updateStatus(key)
recomputeExhausted(key)
}
/** [relay] is unreachable right now: settle it and flag it stalled (kept, retryable). */
fun onCannotConnect(
key: K,
relay: NormalizedRelayUrl,
message: String,
) {
loadTracker.onSettled(relay)
markStalled(key, relay, "cannot connect: $message")
updateStatus(key)
recomputeExhausted(key)
}
// The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag the
// active key's of them stalled (kept) so the window can settle instead of hanging on a dead relay.
private fun onSilenced(relays: Set<NormalizedRelayUrl>) {
val key = activeKey ?: return
relays.forEach { markStalled(key, it, "no response (silence timeout)") }
updateStatus(key)
recomputeExhausted(key)
}
private fun markStalled(
key: K,
relay: NormalizedRelayUrl,
reason: String,
) {
val firstTime = stalledRelays.getOrPut(key) { ConcurrentHashMap.newKeySet() }.add(relay)
if (firstTime) Log.d(TAG) { "[$name] ${relay.url} stalled — $reason (kept, advance to retry)" }
}
// --- Display-flow management. ---
/**
* Repoints the display flows to [key] (call on subscribe / when the on-screen scope changes), then
* refreshes them. A no-op repoint (same key) just refreshes. Cursors in [pager] are untouched, so a
* previously-paged key restores its progress instead of restarting.
*/
fun activate(key: K) {
if (activeKey != key) {
activeKey = key
loadTracker.reset()
_exhausted.value = exhaustedByKey[key] ?: false
_relayCount.value = 0
_stalledCount.value = 0
_reachedBack.value = null
_relayProgress.value = emptyMap()
}
updateStatus(key)
}
/** Recomputes the display flows from [key]'s cursors. No-op when [key] is not the active key. */
fun updateStatus(key: K) {
if (activeKey != key) return
val relays = relaysFor(key) ?: emptySet()
_relayCount.value = loadTracker.count()
val floor = floorFor(key)
_reachedBack.value = pager.deepestReached(key, relays, floor)
val stalled = stalledRelays[key] ?: emptySet()
_stalledCount.value = relays.count { it in stalled && !pager.isDone(key, it) }
_relayProgress.value =
relays.associateWith { relay ->
RelayPagingProgress(
reachedUntil = pager.reachedUntilFor(key, relay, floor),
done = pager.isDone(key, relay),
stalled = relay in stalled && !pager.isDone(key, relay),
)
}
}
// 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(key: K) {
val relays = relaysFor(key) ?: return
if (relays.isEmpty()) return
val stalled = stalledRelays[key] ?: emptySet()
val pending = relays.any { !pager.isDone(key, it) && it !in stalled }
val ex = !pending
val was = exhaustedByKey[key] ?: false
exhaustedByKey[key] = ex
if (ex && !was) {
val done = relays.filter { pager.isDone(key, it) }.map { it.url }
val stuck = relays.filter { it in stalled && !pager.isDone(key, it) }.map { it.url }
Log.d(TAG) { "[$name] window settled (nothing more reachable) — done=$done stalled=$stuck" }
}
if (activeKey == key) _exhausted.value = ex
}
companion object {
private const val TAG = "DMPagination"
const val DEFAULT_PAGE_LIMIT = 10000
// One week — matches the DM live-tail floor (everything newer is the always-on tail's job).
const val DEFAULT_LIVE_TAIL_SECONDS = 7L * TimeUtils.ONE_DAY
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
package com.vitorpamplona.quartz.nip01Core.relay.client.paging
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
package com.vitorpamplona.quartz.nip01Core.relay.client.paging
/** How far back one relay has paged a DM history, for the per-relay progress markers. */
data class RelayPagingProgress(
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
package com.vitorpamplona.quartz.nip01Core.relay.client.paging
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import java.util.concurrent.ConcurrentHashMap
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
package com.vitorpamplona.quartz.nip01Core.relay.client.paging
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener