fix(commons): audit follow-ups for the shared relayClient layer

Review findings on the three migration commits, all verified:

- The shared feed-floor watcher sampled at 5 s for every feed, but the
  video swipe feed used 1 s before the refactor, so paging older videos
  waited up to five times longer. The interval is now a `floorSampleMs`
  override and Video keeps its 1 s cadence.
- `endSub` cancelled the watcher jobs but never dropped the `userJobMap`
  entry, leaving a cancelled job list per account ever subscribed on every
  app-lifetime assembler. It now removes the entry.
- `DesktopLocalCache.consume(nip19)` was a copy of the Android body. The
  NIP-19 seeding is now one default on `ICacheProvider`, built only from
  interface members, with a single `consumeEmbedded(event)` hook for the
  `nembed` branch (Android verifies via `justConsume`, Desktop via its
  `consume(event, relay)`). The default uses `checkGetOrCreateNote`, so an
  invalid id yields no placeholder instead of an exception.
- `allRelayGroupChannels()` dumped the group cache through a predicate that
  always returned true; it now snapshots `values()`.
- The music/podcast kind lists go back to `internal`: their only callers
  moved into commons with the dispatchers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
This commit is contained in:
Claude
2026-09-05 20:59:34 +00:00
parent eb76907720
commit 1e04f7b06e
12 changed files with 61 additions and 110 deletions
@@ -221,15 +221,6 @@ import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.isATag
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -745,7 +736,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
override fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
override fun allRelayGroupChannels(): List<RelayGroupChannel> = relayGroupChannels.filter { _, _ -> true }
override fun allRelayGroupChannels(): List<RelayGroupChannel> = relayGroupChannels.values().toList()
/**
* The [RelayGroupChannel] a group-scoped content [note] belongs to, resolved the same way
@@ -3302,53 +3293,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
wasVerified: Boolean,
): Boolean = !event.isDeleted() && consumeBaseReplaceable(event, relay, wasVerified)
override fun consume(nip19: Entity) {
when (nip19) {
is NSec -> {
getOrCreateUser(nip19.toPubKeyHex())
}
is NPub -> {
getOrCreateUser(nip19.hex)
}
is NProfile -> {
nip19.relay.forEach { relayHint ->
relayHints.addKey(nip19.hex, relayHint)
}
getOrCreateUser(nip19.hex)
}
is NNote -> {
getOrCreateNote(nip19.hex)
}
is NEvent -> {
nip19.relay.forEach { relayHint ->
relayHints.addEvent(nip19.hex, relayHint)
}
val note = getOrCreateNote(nip19.hex)
if (note.author == null) {
nip19.author?.let { note.author = checkGetOrCreateUser(it) }
}
}
is NEmbed -> {
justConsume(nip19.event, null, false)
}
is NRelay -> {}
is NAddress -> {
val aTag = nip19.aTag()
nip19.relay.forEach { relayHint ->
relayHints.addAddress(aTag, relayHint)
}
getOrCreateAddressableNote(nip19.address())
}
else -> { }
}
override fun consumeEmbedded(event: Event) {
justConsume(event, null, false)
}
override fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true)
@@ -31,6 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.utils.Log
/**
@@ -176,7 +183,37 @@ interface ICacheProvider {
* placeholder User / Note / AddressableNote, so a REQ built for it has something to
* attach to. Used by the search sub-assemblers when the query is an npub/nevent/naddr.
*/
fun consume(nip19: Entity)
fun consume(nip19: Entity) {
when (nip19) {
is NSec -> getOrCreateUser(nip19.toPubKeyHex())
is NPub -> getOrCreateUser(nip19.hex)
is NProfile -> {
nip19.relay.forEach { relayHints.addKey(nip19.hex, it) }
getOrCreateUser(nip19.hex)
}
is NNote -> checkGetOrCreateNote(nip19.hex)
is NEvent -> {
nip19.relay.forEach { relayHints.addEvent(nip19.hex, it) }
val note = checkGetOrCreateNote(nip19.hex)
if (note != null && note.author == null) {
nip19.author?.let { note.author = checkGetOrCreateUser(it) }
}
}
is NEmbed -> consumeEmbedded(nip19.event)
is NAddress -> {
val aTag = nip19.aTag()
nip19.relay.forEach { relayHints.addAddress(aTag, it) }
getOrCreateAddressableNote(nip19.address())
}
else -> {}
}
}
/**
* Ingests an event carried inline in an `nembed`. Unlike [justConsumeMyOwnEvent] the event
* did not come from this user, so implementations verify its signature like a relay event.
*/
fun consumeEmbedded(event: Event)
/**
* Every NIP-29 relay group (kind 39000 metadata + rosters) this cache holds. The discovery
@@ -36,8 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
// also fan out as separate subscriptions — keeping them split avoids cross-kind over-fetching
// and lets `makeMusicTracksFilter` / `makeMusicPlaylistsFilter` reuse the same parameterized
// `filterMusicEventsByX` helpers below.
val MUSIC_TRACK_KINDS = listOf(MusicTrackEvent.KIND)
val MUSIC_PLAYLIST_KINDS = listOf(MusicPlaylistEvent.KIND)
internal val MUSIC_TRACK_KINDS = listOf(MusicTrackEvent.KIND)
internal val MUSIC_PLAYLIST_KINDS = listOf(MusicPlaylistEvent.KIND)
fun filterMusicEventsByAuthors(
relay: NormalizedRelayUrl,
@@ -39,15 +39,15 @@ import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMe
// Episodes span both podcast drafts: NIP-F4 kind 54 (podcast-is-a-keypair) and Podcasting-2.0
// kind 30054 (creator-is-a-keypair). Both are authored by the followed pubkey, so the same
// author-scoped REQ pulls them into one merged feed.
val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND)
val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND)
internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND)
internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND)
// Podcasting-2.0 stores show metadata as a kind:30078 NIP-78 app-data event. That kind is
// heavily overloaded, so this REQ MUST be constrained by `#d=["podcast-metadata"]` (see
// [PODCAST_METADATA_D_FILTER]) or it would pull every client's app-data. Kept separate from
// [PODCAST_KINDS] because the NIP-F4 kind:10154 metadata must NOT carry the `#d` constraint.
val PODCASTING20_METADATA_KINDS = listOf(AppSpecificDataEvent.KIND)
val PODCAST_METADATA_D_FILTER = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG))
internal val PODCASTING20_METADATA_KINDS = listOf(AppSpecificDataEvent.KIND)
internal val PODCAST_METADATA_D_FILTER = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG))
/**
* Merges two relay-filter tag maps, unioning the value lists per key. Used to layer an extra
@@ -63,6 +63,9 @@ abstract class TopNavFeedSubAssembler<K : TopNavFeedQueryState>(
/** Runs when the key's top-nav selection changes, right before the filters are re-issued. */
protected open fun onListChanged(key: K) {}
/** How often (ms) a moving feed floor may re-issue the REQ; the video swipe feed wants a tighter cadence. */
protected open val floorSampleMs: Long = 5000
/** The feed floors that re-issue the REQ (sampled) when they move. Defaults to every feed on the key. */
protected open fun floors(key: K): List<StateFlow<Long?>> = key.feeds.map { it.lastNoteCreatedAtWhenFullyLoaded }
@@ -96,7 +99,7 @@ abstract class TopNavFeedSubAssembler<K : TopNavFeedQueryState>(
if (floors.isNotEmpty()) {
add(
key.scope.launch(Dispatchers.IO) {
combine(floors) { }.sample(5000).collectLatest {
combine(floors) { }.sample(floorSampleMs).collectLatest {
invalidateFilters()
}
},
@@ -121,7 +124,7 @@ abstract class TopNavFeedSubAssembler<K : TopNavFeedQueryState>(
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
userJobMap.remove(key)?.forEach { it.cancel() }
}
}
@@ -139,6 +142,7 @@ open class SingleTopNavFeedSubAssembler(
allKeys: () -> Set<TopNavFeedQueryState>,
private val makeFilter: TopNavFeedFilterMaker,
private val resetEoseOnListChange: Boolean = false,
override val floorSampleMs: Long = 5000,
) : TopNavFeedSubAssembler<TopNavFeedQueryState>(client, allKeys) {
override fun updateFilter(
key: TopNavFeedQueryState,
@@ -27,4 +27,4 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
class VideoFilterAssembler(
client: INostrClient,
) : TopNavFeedFilterAssembler<TopNavFeedQueryState>({ keys -> listOf(SingleTopNavFeedSubAssembler(client, keys, ::makePictureAndVideoFilter)) })
) : TopNavFeedFilterAssembler<TopNavFeedQueryState>({ keys -> listOf(SingleTopNavFeedSubAssembler(client, keys, ::makePictureAndVideoFilter, floorSampleMs = 1000)) })
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -193,7 +192,7 @@ class ThreadAssemblerTest {
override fun getOrCreateUser(pubkey: HexKey): User? = null
override fun consume(nip19: Entity) = Unit
override fun consumeEmbedded(event: Event) = Unit
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
@@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
@@ -102,7 +101,7 @@ class ConcordChannelListLeaveTest {
override fun getOrCreateUser(pubkey: HexKey): User? = null
override fun consume(nip19: Entity) = Unit
override fun consumeEmbedded(event: Event) = Unit
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
@@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
@@ -100,7 +99,7 @@ class ConcordListLateArrivalTest {
override fun getOrCreateUser(pubkey: HexKey): User? = null
override fun consume(nip19: Entity) = Unit
override fun consumeEmbedded(event: Event) = Unit
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
@@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
/**
* Minimal [ICacheProvider] for the pure reply-resolution tests in this package: a fixed set of
@@ -69,7 +68,7 @@ internal class StubCache(
override fun getOrCreateUser(pubkey: HexKey): User? = users[pubkey]
override fun consume(nip19: Entity) = Unit
override fun consumeEmbedded(event: Event) = Unit
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
@@ -33,7 +33,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -156,7 +155,7 @@ class EventFinderFilterAssemblyTest {
override fun getOrCreateUser(pubkey: HexKey): User? = null
override fun consume(nip19: Entity) = Unit
override fun consumeEmbedded(event: Event) = Unit
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
@@ -50,14 +50,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -1037,31 +1029,8 @@ class DesktopLocalCache : ICacheProvider {
AddressableNote(address)
}
/** Same seeding as the Android cache: relay hints + a placeholder for whatever the entity names. */
override fun consume(nip19: Entity) {
when (nip19) {
is NSec -> getOrCreateUser(nip19.toPubKeyHex())
is NPub -> getOrCreateUser(nip19.hex)
is NProfile -> {
nip19.relay.forEach { relayHints.addKey(nip19.hex, it) }
getOrCreateUser(nip19.hex)
}
is NNote -> getOrCreateNote(nip19.hex)
is NEvent -> {
nip19.relay.forEach { relayHints.addEvent(nip19.hex, it) }
val note = getOrCreateNote(nip19.hex)
if (note.author == null) {
nip19.author?.let { note.author = checkGetOrCreateUser(it) }
}
}
is NEmbed -> consume(nip19.event, null)
is NAddress -> {
val aTag = nip19.aTag()
nip19.relay.forEach { relayHints.addAddress(aTag, it) }
getOrCreateAddressableNote(nip19.address())
}
else -> {}
}
override fun consumeEmbedded(event: Event) {
consume(event, null)
}
// ----- Channel operations -----