From c512bd39c4531777042bcb6bbedf8a4b6d9fbb27 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 19:07:30 -0400 Subject: [PATCH] refactor(dm): rename UntilLimitPager to RelayLoadingCursors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...6-06-01-dm-live-tail-and-history-slices.md | 12 +-- ...agerTest.kt => RelayLoadingCursorsTest.kt} | 82 +++++++++---------- .../commons/model/privateChats/Chatroom.kt | 4 +- .../model/privateChats/ChatroomList.kt | 6 +- ...ilLimitPager.kt => RelayLoadingCursors.kt} | 2 +- .../relay/client/paging/BackwardRelayPager.kt | 10 +-- .../client/paging/BackwardRelayPagerTest.kt | 10 +-- .../paging/UntilLimitPagingRelayTest.kt | 2 +- 8 files changed, 64 insertions(+), 64 deletions(-) rename amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/{UntilLimitPagerTest.kt => RelayLoadingCursorsTest.kt} (54%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/{UntilLimitPager.kt => RelayLoadingCursors.kt} (99%) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index 2f51b44378..aff6779402 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt similarity index 54% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt index 597e783493..7f5237e7dc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt @@ -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(), pager.armedRelays(listOf(relayA))) + assertEquals(emptyList(), 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(), 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(), 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)) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 0379f030ae..995e5ae27c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -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() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt index 6585317fa5..ccb294281c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt @@ -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() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt index 8e0431d2db..37453b1779 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -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 diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt index 29578c236f..32656295cf 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt @@ -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? = { null } @@ -133,7 +133,7 @@ class BackwardRelayPager( * instead of restarting. */ fun bind( - scopeCursors: UntilLimitPager, + scopeCursors: RelayLoadingCursors, scope: CoroutineScope, relaysForScope: () -> Collection?, ) { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt index 2f8b9c9126..74e0bb4428 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt @@ -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 { - val cursors = UntilLimitPager() + private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair { + 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). diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt index d422ec580a..676872671d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt @@ -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 —