refactor(dm): rename UntilLimitPager to RelayLoadingCursors

Once the pager was split into the orchestrator (BackwardRelayPager) and the
pure per-relay cursor state that lives on the model, "UntilLimitPager" no longer
described the latter — it pages nothing, it just records how far each relay has
loaded. Rename it (and its test) to RelayLoadingCursors.

The geode wire-contract test keeps its name (UntilLimitPagingRelayTest): it
pins the relay-side `until`+`limit` paging behaviour, not the class.

Pure rename — no behaviour change. Design doc updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-05 19:07:30 -04:00
co-authored by Claude Opus 4.8
parent b37f1a6e56
commit c512bd39c4
8 changed files with 64 additions and 64 deletions
@@ -41,7 +41,7 @@ Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs
when all settle, drives the boot spinner.
2. **History** — everything *older* than the week floor, paged **backward by
`until`+`limit`, per relay, on demand**. Backed by the **per-relay model**
(`UntilLimitPager` + `PerRelayLoadTracker`), driven by on-screen markers.
(`RelayLoadingCursors` + `PerRelayLoadTracker`), driven by on-screen markers.
The two are disjoint in time, so re-issuing a history page never re-streams the
live tail, and consecutive history pages never re-stream each other.
@@ -56,7 +56,7 @@ Accessed from the UI via `accountViewModel.dataSources()` as
`.account.giftWrapsHistory`, `.chatroom.nip04History`,
`.chatroomList.nip04History`.
### The history paging primitive: `UntilLimitPager`
### The history paging primitive: `RelayLoadingCursors`
The time-window model can't tell "this relay is empty" from "this is a gap" — a
`since`/`until` slice that returns nothing might just be a quiet stretch above
@@ -76,7 +76,7 @@ Stop signals: an empty page marks the relay **`done`**. A relay returning fewer
than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay
that returns events but none older than already reached (echoing its newest
events) is also treated as the bottom, so its marker can't re-request the same
window forever. Tested in `UntilLimitPagerTest.kt`.
window forever. Tested in `RelayLoadingCursorsTest.kt`.
### The two completion models (and where each lives)
@@ -92,7 +92,7 @@ the one-shot fixed-window backfill the live tail does.
> in current use only the settle / idle / cap paths ever fire. The REQ-aware
> machinery is dormant in production — see "Things to scrutinize".
**Per-relay model — `UntilLimitPager` + `PerRelayLoadTracker` (all history).**
**Per-relay model — `RelayLoadingCursors` + `PerRelayLoadTracker` (all history).**
Each relay advances to its next page the instant *it* EOSEs, independent of the
others; the subscription layer diffs per relay, so re-issuing only re-REQs the
relay whose cursor moved. `loading` starts **`false`** (a `true` start would
@@ -231,7 +231,7 @@ pagination, but it lived here because unreachable relays were part of the same
out of amethyst so desktop / CLI / any feed can reuse it; in the `jvmAndroid`
source set (uses `java.util.concurrent`), visible to amethyst + desktop + quartz's
`jvmAndroidTest` (geode in-process relay).
- `UntilLimitPager.kt` — per-relay `until`+`limit` cursor. *(+ `UntilLimitPagerTest` in amethyst)*
- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor. *(+ `RelayLoadingCursorsTest` in amethyst)*
- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog.
- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ silence test in amethyst)*
- `RelayPagingProgress.kt``(reachedUntil, done, stalled)` per relay.
@@ -291,7 +291,7 @@ strings, no app-theme / `java.time` deps.
These sections describe earlier iterations, kept for context. The code has
moved past all of them.
### v1 — time-slice history (superseded by `UntilLimitPager`)
### v1 — time-slice history (superseded by `RelayLoadingCursors`)
History was first loaded in bounded `since`+`until` **time slices**
(`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band
@@ -20,90 +20,90 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class UntilLimitPagerTest {
class RelayLoadingCursorsTest {
private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.relay")!!
private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.relay")!!
private val start = 1_000L
@Test
fun unarmedRelayIsNotRequestedAndSitsAtTheFloor() {
val pager = UntilLimitPager()
val cursors = RelayLoadingCursors()
// never advanced, so it carries no REQ
assertEquals(emptyList<Any>(), pager.armedRelays(listOf(relayA)))
assertEquals(emptyList<Any>(), cursors.armedRelays(listOf(relayA)))
// marker sits at the floor until it delivers
assertEquals(start, pager.reachedUntilFor(relayA, start))
assertEquals(start, cursors.reachedUntilFor(relayA, start))
}
@Test
fun firstAdvanceRequestsTheFloorThenSubsequentPagesStepBelowReached() {
val pager = UntilLimitPager()
val cursors = RelayLoadingCursors()
assertTrue(pager.advance(relayA, start))
assertEquals(start, pager.requestedUntilFor(relayA))
assertTrue(cursors.advance(relayA, start))
assertEquals(start, cursors.requestedUntilFor(relayA))
// page returns events; oldest seen = 800
pager.onEvent(relayA, 900)
pager.onEvent(relayA, 800)
pager.onEose(relayA)
assertEquals(800L, pager.reachedUntilFor(relayA, start))
cursors.onEvent(relayA, 900)
cursors.onEvent(relayA, 800)
cursors.onEose(relayA)
assertEquals(800L, cursors.reachedUntilFor(relayA, start))
// EOSE does NOT move the requested cursor — the relay parks at the same filter
assertEquals(start, pager.requestedUntilFor(relayA))
assertEquals(start, cursors.requestedUntilFor(relayA))
// next advance steps to reached - 1
assertTrue(pager.advance(relayA, start))
assertEquals(799L, pager.requestedUntilFor(relayA))
assertTrue(cursors.advance(relayA, start))
assertEquals(799L, cursors.requestedUntilFor(relayA))
}
@Test
fun emptyPageMarksRelayDoneAndBlocksFurtherAdvance() {
val pager = UntilLimitPager()
pager.advance(relayA, start)
pager.onEose(relayA) // no events
assertTrue(pager.isDone(relayA))
assertFalse(pager.advance(relayA, start))
assertEquals(emptyList<Any>(), pager.armedRelays(listOf(relayA)))
val cursors = RelayLoadingCursors()
cursors.advance(relayA, start)
cursors.onEose(relayA) // no events
assertTrue(cursors.isDone(relayA))
assertFalse(cursors.advance(relayA, start))
assertEquals(emptyList<Any>(), cursors.armedRelays(listOf(relayA)))
}
@Test
fun aPageThatDoesNotStepOlderEndsTheRelayInsteadOfLooping() {
val pager = UntilLimitPager()
pager.advance(relayA, start)
pager.onEvent(relayA, 800)
pager.onEose(relayA)
assertEquals(800L, pager.reachedUntilFor(relayA, start))
val cursors = RelayLoadingCursors()
cursors.advance(relayA, start)
cursors.onEvent(relayA, 800)
cursors.onEose(relayA)
assertEquals(800L, cursors.reachedUntilFor(relayA, start))
// misbehaving relay: next page echoes an event no older than what we already reached
pager.advance(relayA, start) // requested = 799
pager.onEvent(relayA, 900) // newer than reached(800) — not strictly older
pager.onEose(relayA)
assertTrue("a non-advancing page should end the relay, not re-loop", pager.isDone(relayA))
assertEquals(800L, pager.reachedUntilFor(relayA, start))
cursors.advance(relayA, start) // requested = 799
cursors.onEvent(relayA, 900) // newer than reached(800) — not strictly older
cursors.onEose(relayA)
assertTrue("a non-advancing page should end the relay, not re-loop", cursors.isDone(relayA))
assertEquals(800L, cursors.reachedUntilFor(relayA, start))
}
@Test
fun relaysAreTrackedIndependently() {
val pager = UntilLimitPager()
pager.advance(relayA, start)
pager.onEvent(relayA, 500)
pager.onEose(relayA)
val cursors = RelayLoadingCursors()
cursors.advance(relayA, start)
cursors.onEvent(relayA, 500)
cursors.onEose(relayA)
// B never advanced
assertEquals(listOf(relayA), pager.armedRelays(listOf(relayA, relayB)))
assertEquals(500L, pager.reachedUntilFor(relayA, start))
assertEquals(start, pager.reachedUntilFor(relayB, start))
assertEquals(listOf(relayA), cursors.armedRelays(listOf(relayA, relayB)))
assertEquals(500L, cursors.reachedUntilFor(relayA, start))
assertEquals(start, cursors.reachedUntilFor(relayB, start))
// deepest reached across both = A's 500 (B counts as the floor)
assertEquals(500L, pager.deepestReached(listOf(relayA, relayB), start))
assertEquals(500L, cursors.deepestReached(listOf(relayA, relayB), start))
}
@Test
fun deepestReachedIsNullWhenNoRelays() {
val pager = UntilLimitPager()
assertEquals(null, pager.deepestReached(emptyList(), start))
val cursors = RelayLoadingCursors()
assertEquals(null, cursors.deepestReached(emptyList(), start))
}
}
@@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.WeakReference
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -51,7 +51,7 @@ class Chatroom : NotesGatherer {
// progress and the cursors share the lifetime of the cached messages. The conversation history
// loader binds its (single-active) orchestrator to this. Lazy — most rooms in the rooms list are
// never opened for history paging, so they never allocate it.
val nip04History by lazy { UntilLimitPager() }
val nip04History by lazy { RelayLoadingCursors() }
// Per-instance lock shared by previously @Synchronized methods.
private val syncLock = KmpLock()
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.privateChats
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -39,8 +39,8 @@ class ChatroomList(
// lifetime of the cached messages and are dropped when the cache prunes them. The account-level
// history loaders bind their orchestrator to these. (Per-conversation NIP-04 cursors live on the
// individual [Chatroom] instead.)
val giftWrapHistory = UntilLimitPager()
val nip04History = UntilLimitPager()
val giftWrapHistory = RelayLoadingCursors()
val nip04History = RelayLoadingCursors()
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() }
@@ -57,7 +57,7 @@ import kotlin.concurrent.Volatile
* thread-safe [LargeCache]. Keyed only by [NormalizedRelayUrl], which is `Comparable` and consistent
* with `equals`, so the sorted cache identifies relays correctly.
*/
class UntilLimitPager {
class RelayLoadingCursors {
private class RelayCursor {
// The `until` the REQ carries; null until the relay is first advanced. Moves only in advance().
@Volatile var requestedUntil: Long? = null
@@ -31,11 +31,11 @@ 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
* **one page at a time, per relay, on demand**, by `until`+`limit` ([RelayLoadingCursors]) — with each
* relay advancing independently the moment *it* settles, never paced by the slowest one.
*
* This is the **single-active orchestrator** around the paging state. The state itself (the per-relay
* [UntilLimitPager] cursors) does NOT live here — it lives on the owning domain object (a `Chatroom`
* [RelayLoadingCursors] cursors) does NOT live here — it lives on the owning domain object (a `Chatroom`
* for a conversation, a `ChatroomList` for the account-level feeds), so its lifetime matches the cached
* messages it describes. One orchestrator drives whichever scope is on screen; calling [bind] repoints
* it at that scope's cursors. This is safe because history relays only ever arm while their on-screen
@@ -44,7 +44,7 @@ import java.util.concurrent.ConcurrentHashMap
* 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
* history floor live on the bound [UntilLimitPager].
* history floor live on the bound [RelayLoadingCursors].
*
* 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
@@ -81,7 +81,7 @@ class BackwardRelayPager(
// The active scope, set by [bind]: its persistent per-relay cursors (which live on the owning domain
// object) and the lookup for the relay set it fans out to.
@Volatile
private var cursors: UntilLimitPager? = null
private var cursors: RelayLoadingCursors? = null
@Volatile
private var relaysFor: () -> Collection<NormalizedRelayUrl>? = { null }
@@ -133,7 +133,7 @@ class BackwardRelayPager(
* instead of restarting.
*/
fun bind(
scopeCursors: UntilLimitPager,
scopeCursors: RelayLoadingCursors,
scope: CoroutineScope,
relaysForScope: () -> Collection<NormalizedRelayUrl>?,
) {
@@ -38,7 +38,7 @@ import kotlin.test.assertTrue
* behaviour is covered separately against the in-process relay in `UntilLimitPagingRelayTest`.
*
* The pager is the **single-active orchestrator**: its per-relay cursors live on a separate
* [UntilLimitPager] (in production, on a `Chatroom` / `ChatroomList`), bound in via [bind]. These tests
* [RelayLoadingCursors] (in production, on a `Chatroom` / `ChatroomList`), bound in via [bind]. These tests
* supply their own cursor object so they can rebind a previously-paged scope and assert what persists
* (the cursors) versus what is transient and recomputed (the stalled set, the live flows).
*/
@@ -55,8 +55,8 @@ class BackwardRelayPagerTest {
}
// A pager bound to a fresh scope of [relays]; returns both so tests can read the pinned cursor floor.
private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair<BackwardRelayPager, UntilLimitPager> {
val cursors = UntilLimitPager()
private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair<BackwardRelayPager, RelayLoadingCursors> {
val cursors = RelayLoadingCursors()
val p = BackwardRelayPager("test")
p.bind(cursors, scope) { relays.toList() }
return p to cursors
@@ -208,8 +208,8 @@ class BackwardRelayPagerTest {
@Test
fun rebindingRepointsFlowsKeepingDoneCursorsButDroppingTransientStalls() {
val cursorsA = UntilLimitPager()
val cursorsB = UntilLimitPager()
val cursorsA = RelayLoadingCursors()
val cursorsB = RelayLoadingCursors()
val p = BackwardRelayPager("test")
// Scope A: r1 bottoms out (done — a persistent cursor fact); r2 auth-walls (stalled — transient).
@@ -32,7 +32,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Pins down the relay-side contract the whole [UntilLimitPager] / [BackwardRelayPager] design rests on,
* Pins down the relay-side contract the whole [RelayLoadingCursors] / [BackwardRelayPager] design rests on,
* against the in-process relay: a backward `until`+`limit` walk returns each event **exactly once**
* (no re-download), in **newest-first** capped pages, and an **empty page + EOSE** is the gap-proof
* stop. If a relay ever stopped honouring this (e.g. oldest-first, or ignoring `until`), these break