feat(relays): merge account metadata across accounts into one REQ

Account metadata was the largest single contributor to blowing a relay's
subscription cap: seven filters in a subscription, repeated once per logged-in
account. Every one of them is `authors`-keyed, so a relay that several accounts
read from can be asked about all of them by widening `authors` — the same shape
of merge the notification tail just took, and for the same reason.

The per-account `limit`s are summed rather than shared. These are mostly
replaceable events, so the limit is a safety bound rather than a page size, and
scaling it by the number of accounts leaves each one exactly the headroom it had
alone. That is the difference from gift wraps, which stay per-account because
their `limit` IS a page size over unsolicited content.

Measured on emulator-5554, four accounts, cold start, counting nos.lol's
`ERROR: too many concurrent REQs`: 13 before either merge, 11 after
notifications, 8 after this. The subscription count on that relay went from
24-26 to 23, against its cap of 20.

So the per-account multiplication is no longer the driver, and the remaining 23
are not account-level at all — they are the feed, channel and finder assemblers.
Cutting further means looking there, not at more merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-31 11:56:03 -04:00
co-authored by Claude Opus 5
parent 24decefd4b
commit fe2d7ef045
6 changed files with 89 additions and 59 deletions
@@ -20,66 +20,96 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
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.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Each account's own profile, lists and recent posts — for **every** logged-in account, in one
* subscription.
*
* Every filter here is `authors`-keyed, so a relay that several accounts read from can be asked
* about all of them at once by widening `authors` rather than opening a REQ per account. This was
* the largest single contributor to blowing a relay's `max_subscriptions`: seven filters in a
* subscription, repeated once per account.
*
* The per-account `limit`s are summed rather than shared. These are mostly replaceable events, so
* the limit is a safety bound rather than a page size, and scaling it by the number of accounts
* keeps each one exactly the headroom it had alone.
*/
class AccountMetadataEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
) : SingleSubEoseManager<AccountQueryState>(client, allKeys) {
override fun distinct(key: AccountQueryState) = key.account.userProfile()
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
override fun updateFilter(
key: AccountQueryState,
keys: List<AccountQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
relayFlow(key).value.flatMap {
val since = since?.get(it)?.time
listOf(
filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since),
filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since),
filterBookmarksAndReportsFromKey(it, user(key).pubkeyHex, since),
filterLastPostsFromKey(it, user(key).pubkeyHex, since ?: TimeUtils.oneMonthAgo()),
filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since),
).flatten()
): List<RelayBasedFilter> {
val accountsPerRelay = mutableMapOf<NormalizedRelayUrl, MutableList<AccountQueryState>>()
keys.forEach { key ->
relayFlow(key).value.forEach { relay ->
accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key)
}
}
val userJobMap = mutableMapOf<User, List<Job>>()
return accountsPerRelay.flatMap { (relay, accounts) ->
val pubkeys = accounts.map { it.account.userProfile().pubkeyHex }
val relaySince = since?.get(relay)?.time
// The account-switcher avatars: other logged-in accounts this screen wants to name.
// Screens supply them; the background registry does not, so this is usually empty.
val otherAccounts = accounts.flatMapTo(mutableSetOf()) { it.otherAccounts }.minus(pubkeys.toSet())
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
relayFlow(key).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
filterAccountInfoAndListsFromKey(relay, pubkeys, relaySince),
filterFollowsAndMutesFromKey(relay, pubkeys, relaySince),
filterBookmarksAndReportsFromKey(relay, pubkeys, relaySince),
filterLastPostsFromKey(relay, pubkeys, relaySince ?: TimeUtils.oneMonthAgo()),
filterBasicAccountInfoFromKeys(relay, otherAccounts.toList(), relaySince),
).flatten()
}
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
/** Per-account relay watchers, reconciled as accounts come and go. See the notifications manager. */
private val userJobMap = mutableMapOf<User, List<Job>>()
override fun updateSubscriptions(keys: Set<AccountQueryState>) {
val wanted = keys.associateBy { it.account.userProfile() }
(userJobMap.keys - wanted.keys).toList().forEach { user ->
userJobMap.remove(user)?.forEach { it.cancel() }
}
wanted.forEach { (user, key) ->
if (user !in userJobMap) {
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
relayFlow(key).collectLatest { invalidateFilters() }
},
)
}
}
super.updateSubscriptions(keys)
}
override fun destroy() {
userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } }
userJobMap.clear()
super.destroy()
}
}
@@ -109,10 +109,10 @@ val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG))
fun filterAccountInfoAndListsFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
@@ -121,8 +121,8 @@ fun filterAccountInfoAndListsFromKey(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
kinds = AccountInfoAndListsFromKeyKinds,
authors = listOf(pubkey),
limit = 20,
authors = pubkeys,
limit = 20 * pubkeys.size,
since = since,
),
),
@@ -132,8 +132,8 @@ fun filterAccountInfoAndListsFromKey(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
kinds = AccountInfoAndListsFromKeyKinds2,
authors = listOf(pubkey),
limit = 80,
authors = pubkeys,
limit = 80 * pubkeys.size,
since = since,
),
),
@@ -141,7 +141,7 @@ fun filterAccountInfoAndListsFromKey(
// Addressable — one card per target user — hence its own larger-limit filter.
filterContactCardsByAuthorInTheRelay(
relay = relay,
author = pubkey,
authors = pubkeys,
since = since,
),
RelayBasedFilter(
@@ -150,9 +150,9 @@ fun filterAccountInfoAndListsFromKey(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
kinds = AmethystMetadataKinds,
authors = listOf(pubkey),
authors = pubkeys,
tags = AmethystMetadataTagMapFilter,
limit = 1,
limit = 1 * pubkeys.size,
since = since,
),
),
@@ -44,10 +44,10 @@ val ReportsAndBookmarksFromKeyKinds =
fun filterBookmarksAndReportsFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
@@ -56,7 +56,7 @@ fun filterBookmarksAndReportsFromKey(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
kinds = ReportsAndBookmarksFromKeyKinds,
authors = listOf(pubkey),
authors = pubkeys,
since = since,
),
),
@@ -48,10 +48,10 @@ val FollowAndMutesFromKeyKinds =
fun filterFollowsAndMutesFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
@@ -60,8 +60,8 @@ fun filterFollowsAndMutesFromKey(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
kinds = FollowAndMutesFromKeyKinds,
authors = listOf(pubkey),
limit = 100,
authors = pubkeys,
limit = 100 * pubkeys.size,
since = since,
),
),
@@ -28,10 +28,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterLastPostsFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
@@ -39,8 +39,8 @@ fun filterLastPostsFromKey(
filter =
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
authors = listOf(pubkey),
limit = 100,
authors = pubkeys,
limit = 100 * pubkeys.size,
since = since,
),
),
@@ -65,7 +65,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
*/
fun filterContactCardsByAuthorInTheRelay(
relay: NormalizedRelayUrl,
author: HexKey,
authors: List<HexKey>,
since: Long?,
limit: Int = 500,
): RelayBasedFilter =
@@ -75,9 +75,9 @@ fun filterContactCardsByAuthorInTheRelay(
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
// This variant fetches an account's OWN contact card, so the author is the owner.
accountPubKeys = listOfNotNull(author),
accountPubKeys = authors,
kinds = ContactCardKindList,
authors = listOf(author),
authors = authors,
limit = limit,
since = since,
),