mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
perf: warm NIP-29 relays' NIP-11 in parallel + pre-warm joined groups
The relay-signed group check reads each host relay's NIP-11 `self` from cache,
but the discovery screen was warming those docs SERIALLY — Nip11Retriever awaits
each HTTP fetch, so N relays meant N sequential round-trips and one slow or
unreachable relay stalled every group behind it until its socket timeout. That's
why groups trickled in.
- Add WarmNip11(relays): fans the fetches out, one coroutine each, so the wait is
the slowest single fetch instead of the sum. Discovery now uses it and
re-invalidates the feed as each doc lands (via a version counter).
- Pre-warm NIP-11 for joined groups' host relays from the Messages tab
(WarmJoinedRelayGroupNip11), so by the time one surfaces in discovery the
answer is a cache hit. NIP-11 stays cached for an hour.
Note: the Messages tab itself never needed this — joined ("My Groups") rows are
authoritative from the kind-10009 list and were never gated on NIP-11.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
+32
@@ -21,16 +21,48 @@
|
||||
package com.vitorpamplona.amethyst.model.nip11RelayInfo
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun loadRelayInfo(relay: NormalizedRelayUrl): State<Nip11RelayInformation> = loadRelayInfo(relay, Amethyst.instance.nip11Cache)
|
||||
|
||||
/**
|
||||
* Eagerly warms the NIP-11 cache for a whole set of [relays] **in parallel** (each fetch on its own
|
||||
* coroutine), so callers that later read the cache — e.g. the NIP-29 relay-signed group check — get
|
||||
* hits instead of cold fetches. Warming a set serially would sum every relay's latency and let one
|
||||
* slow/unreachable relay stall the rest until its socket timeout; fanning out bounds the wait to the
|
||||
* slowest single fetch. [onEachLoaded] fires (on an IO thread) as each relay resolves, so a screen
|
||||
* can re-evaluate incrementally as docs arrive. The cache dedups, so re-warming is cheap.
|
||||
*/
|
||||
@Composable
|
||||
fun WarmNip11(
|
||||
relays: Collection<NormalizedRelayUrl>,
|
||||
onEachLoaded: () -> Unit = {},
|
||||
) {
|
||||
val cache = Amethyst.instance.nip11Cache
|
||||
LaunchedEffect(relays) {
|
||||
coroutineScope {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
cache.loadRelayInfo(
|
||||
relay = relay,
|
||||
onInfo = { onEachLoaded() },
|
||||
onError = { _, _, _ -> onEachLoaded() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun loadRelayInfo(
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+9
-18
@@ -42,7 +42,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -53,7 +53,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
@@ -221,29 +221,20 @@ private fun WatchAccountForRelayGroupDiscovery(
|
||||
|
||||
// Discovery only shows groups whose 39000 is signed by the host relay's own key (NIP-29's
|
||||
// authority — the NIP-11 `self` pubkey; general relays instead carry stray user-published 39000s
|
||||
// that can't be joined). The feed reads that from each relay's cached NIP-11, so warm the
|
||||
// candidate relays here and re-invalidate as each doc resolves — otherwise a relay whose NIP-11
|
||||
// lands after its 39000s would stay hidden until a manual refresh.
|
||||
// that can't be joined). The feed reads that from each relay's cached NIP-11, so warm every
|
||||
// candidate relay (in parallel — WarmNip11) and re-invalidate as each doc lands, otherwise a
|
||||
// relay whose NIP-11 resolves after its 39000s would stay hidden until a manual refresh.
|
||||
val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys }
|
||||
var nip11Loaded by remember { mutableStateOf(emptySet<NormalizedRelayUrl>()) }
|
||||
LaunchedEffect(candidateRelays) {
|
||||
val loaded = nip11Loaded.toMutableSet()
|
||||
candidateRelays.forEach { relay ->
|
||||
Amethyst.instance.nip11Cache.loadRelayInfo(
|
||||
relay = relay,
|
||||
onInfo = { if (loaded.add(relay)) nip11Loaded = loaded.toSet() },
|
||||
onError = { _, _, _ -> if (loaded.add(relay)) nip11Loaded = loaded.toSet() },
|
||||
)
|
||||
}
|
||||
}
|
||||
var nip11Version by remember { mutableIntStateOf(0) }
|
||||
WarmNip11(candidateRelays) { nip11Version++ }
|
||||
|
||||
LaunchedEffect(listName, perRelay, joinedGroups) {
|
||||
feedContentState.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
|
||||
// A NIP-11 doc resolving doesn't change the feed key (the self-key test lives in the filter's
|
||||
// match, not the key), so force a re-filter directly when one lands.
|
||||
LaunchedEffect(nip11Loaded) {
|
||||
// match, not the key), so force a re-filter directly as each one lands.
|
||||
LaunchedEffect(nip11Version) {
|
||||
feedContentState.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* Eagerly warms the NIP-11 documents of the host relays of every group the user has joined, in
|
||||
* parallel. The NIP-29 relay-signed check ([com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup])
|
||||
* reads those docs from cache, so pre-warming them from a primary tab (Messages) means the moment
|
||||
* one of these groups also surfaces in discovery — or any relay-signed gate runs — the answer is a
|
||||
* cache hit rather than a cold fetch. Cheap: the cache dedups and holds each doc for an hour.
|
||||
*/
|
||||
@Composable
|
||||
fun WarmJoinedRelayGroupNip11(accountViewModel: AccountViewModel) {
|
||||
val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers
|
||||
.collectAsStateWithLifecycle()
|
||||
val relays = remember(servers) { servers.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } }
|
||||
WarmNip11(relays)
|
||||
}
|
||||
+5
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
|
||||
@@ -105,6 +106,10 @@ fun MessagesSinglePane(
|
||||
// membership/pending state is accurate inline without opening each chat.
|
||||
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
|
||||
|
||||
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a cache hit
|
||||
// when those groups surface in discovery or any gated surface.
|
||||
WarmJoinedRelayGroupNip11(accountViewModel)
|
||||
|
||||
// The inline-vs-grouped NIP-29 display preference lives in Settings › Messages; joined groups
|
||||
// (or per-relay rows in grouped mode) are woven directly into the feed below.
|
||||
MessagesPager(
|
||||
|
||||
+5
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.WarmJoinedRelayGroupNip11
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
@@ -95,6 +96,10 @@ fun MessagesTwoPane(
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) {
|
||||
RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel)
|
||||
|
||||
// Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a
|
||||
// cache hit when those groups surface in discovery or any gated surface.
|
||||
WarmJoinedRelayGroupNip11(accountViewModel)
|
||||
|
||||
// The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined
|
||||
// groups (or per-relay rows in grouped mode) are woven into the list itself.
|
||||
ChatroomList(
|
||||
|
||||
Reference in New Issue
Block a user