diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt index 576b50ae1..46b3acc1c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/BoundedLargeCache.kt @@ -22,24 +22,22 @@ package com.vitorpamplona.amethyst.desktop.cache import com.vitorpamplona.quartz.utils.cache.CacheCollectors import com.vitorpamplona.quartz.utils.cache.LargeCache +import java.util.concurrent.atomic.AtomicInteger /** * A bounded wrapper around [LargeCache] that enforces a maximum size. * - * When the cache exceeds [maxSize], the oldest entries (by key order) are evicted. - * Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs - * (filterIntoSet, mapNotNull, etc.) matching Android's LocalCache patterns. + * When the cache exceeds [maxSize], entries are evicted by key order. + * Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs. * - * Chosen over LruCache because: - * - Lock-free reads via ConcurrentSkipListMap (vs synchronized on every get()) - * - Rich query API matching Android's filter patterns - * - No snapshot copy overhead for iteration + * Size tracking uses AtomicInteger (O(1)) instead of ConcurrentSkipListMap.size() (O(n)). */ class BoundedLargeCache, V>( private val maxSize: Int, private val evictPercent: Float = 0.1f, ) { private val inner = LargeCache() + private val sizeCounter = AtomicInteger(0) fun get(key: K): V? = inner.get(key) @@ -47,7 +45,9 @@ class BoundedLargeCache, V>( key: K, value: V, ) { + val existing = inner.get(key) inner.put(key, value) + if (existing == null) sizeCounter.incrementAndGet() enforceSize() } @@ -55,20 +55,33 @@ class BoundedLargeCache, V>( key: K, builder: (K) -> V, ): V { + val existing = inner.get(key) + if (existing != null) return existing val result = inner.getOrCreate(key, builder) + // Increment if we were the ones who created it (not a concurrent insert) + if (inner.get(key) === result) { + sizeCounter.incrementAndGet() + } enforceSize() return result } - fun remove(key: K): V? = inner.remove(key) + fun remove(key: K): V? { + val removed = inner.remove(key) + if (removed != null) sizeCounter.decrementAndGet() + return removed + } fun containsKey(key: K): Boolean = inner.containsKey(key) - fun size(): Int = inner.size() + fun size(): Int = sizeCounter.get() - fun isEmpty(): Boolean = inner.isEmpty() + fun isEmpty(): Boolean = sizeCounter.get() == 0 - fun clear() = inner.clear() + fun clear() { + inner.clear() + sizeCounter.set(0) + } fun keys(): Set = inner.keys() @@ -83,11 +96,15 @@ class BoundedLargeCache, V>( fun count(consumer: CacheCollectors.BiFilter): Int = inner.count(consumer) private fun enforceSize() { - val currentSize = inner.size() + val currentSize = sizeCounter.get() if (currentSize > maxSize) { val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1) val keys = inner.keys().take(toRemove) - keys.forEach { inner.remove(it) } + keys.forEach { + if (inner.remove(it) != null) { + sizeCounter.decrementAndGet() + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index b382d4be2..a83a864cd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -295,12 +295,13 @@ class DesktopLocalCache : ICacheProvider { * Consumes a kind 3 contact list event (replaceable). * Updates the cached followedUsers set. */ + private var lastContactListCreatedAt = 0L + private fun consumeContactList(event: ContactListEvent): Boolean { - val currentFollows = _followedUsers.value - val newFollows = event.verifiedFollowKeySet() - if (newFollows != currentFollows) { - _followedUsers.value = newFollows - } + // Replaceable event — only accept newer contact lists + if (event.createdAt <= lastContactListCreatedAt) return false + lastContactListCreatedAt = event.createdAt + _followedUsers.value = event.verifiedFollowKeySet() return true } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 073dcad31..8f87d901a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -94,19 +94,20 @@ class DesktopThreadFilter( override fun feed(): List { val root = cache.getNoteIfExists(noteId) ?: return emptyList() - val result = mutableListOf(root) - collectReplies(root, result) - return result.sortedWith(compareBy { it.createdAt() ?: 0 }) + // Use LinkedHashSet for O(1) containment checks (was O(R) with MutableList) + val seen = LinkedHashSet() + seen.add(root) + collectReplies(root, seen) + return seen.sortedWith(compareBy { it.createdAt() ?: 0 }) } private fun collectReplies( note: Note, - result: MutableList, + seen: LinkedHashSet, ) { for (reply in note.replies) { - if (reply !in result) { - result.add(reply) - collectReplies(reply, result) + if (seen.add(reply)) { + collectReplies(reply, seen) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 1f267a177..70e5dceee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -120,15 +121,16 @@ class DesktopRelaySubscriptionsCoordinator( lastEventReceivedAt: Long? = null, eoseReceived: Boolean? = null, ) { - _subscriptionHealth.value = - _subscriptionHealth.value.toMutableMap().apply { - val current = this[subId] ?: SubscriptionHealth() + _subscriptionHealth.update { current -> + current.toMutableMap().apply { + val existing = this[subId] ?: SubscriptionHealth() this[subId] = - current.copy( - lastEventReceivedAt = lastEventReceivedAt ?: current.lastEventReceivedAt, - eoseReceived = eoseReceived ?: current.eoseReceived, + existing.copy( + lastEventReceivedAt = lastEventReceivedAt ?: existing.lastEventReceivedAt, + eoseReceived = eoseReceived ?: existing.eoseReceived, ) } + } } /** @@ -150,7 +152,7 @@ class DesktopRelaySubscriptionsCoordinator( } } } catch (e: Exception) { - println("Coordinator: failed to consume kind ${event.kind}: ${e.message}") + println("Coordinator: failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}") } } } @@ -230,8 +232,7 @@ class DesktopRelaySubscriptionsCoordinator( fun releaseInteractions(subId: String) { screenSubscriptions.remove(subId)?.cancel() client.close(subId) - _subscriptionHealth.value = - _subscriptionHealth.value.toMutableMap().apply { remove(subId) } + _subscriptionHealth.update { it - subId } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 1b0b8063f..64c02825d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -204,17 +204,19 @@ fun FeedScreen( } } - // Request interaction subscriptions (zaps, reactions, reposts) for visible notes - DisposableEffect(feedState, subscriptionsCoordinator) { + // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms) + DisposableEffect(feedMode, subscriptionsCoordinator) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} - val notes = viewModel.feedState.visibleNotes() - val noteIds = notes.mapNotNull { it.event?.id } - if (noteIds.isEmpty()) return@DisposableEffect onDispose {} - - val relays = - relayManager.relayStatuses.value.keys - val subId = coordinator.requestInteractions(noteIds, relays) - onDispose { coordinator.releaseInteractions(subId) } + val relays = relayManager.relayStatuses.value.keys + // Initial subscription with whatever notes are visible now + val noteIds = viewModel.feedState.visibleNotes().mapNotNull { it.event?.id } + val subId = + if (noteIds.isNotEmpty()) { + coordinator.requestInteractions(noteIds, relays) + } else { + null + } + onDispose { subId?.let { coordinator.releaseInteractions(it) } } } @OptIn(ExperimentalLayoutApi::class) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index b3c3a1192..4b6962c24 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -155,15 +155,18 @@ fun ThreadScreen( } } - // Request interaction data (zaps, reactions, reposts) for visible thread notes - DisposableEffect(threadNotes, subscriptionsCoordinator) { + // Request interaction data — keyed on noteId (stable), not threadNotes (changes on every bundle) + DisposableEffect(noteId, subscriptionsCoordinator) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} val noteIds = threadNotes.mapNotNull { it.event?.id } - if (noteIds.isEmpty()) return@DisposableEffect onDispose {} - val relays = relayManager.relayStatuses.value.keys - val subId = coordinator.requestInteractions(noteIds, relays) - onDispose { coordinator.releaseInteractions(subId) } + val subId = + if (noteIds.isNotEmpty()) { + coordinator.requestInteractions(noteIds, relays) + } else { + null + } + onDispose { subId?.let { coordinator.releaseInteractions(it) } } } // Load metadata for thread authors via coordinator