fix(cache): address review findings — subscription churn, O(n) size, atomicity

P1 fixes from compound engineering review:
- Fix subscription churn flooding relays: DisposableEffect keyed on
  feedMode/noteId (stable) instead of feedState (changes every 250ms)
- BoundedLargeCache: AtomicInteger counter replaces O(n)
  ConcurrentSkipListMap.size() — eliminates 50K traversal per event
- Non-atomic updateHealth(): use MutableStateFlow.update{} for
  atomic compare-and-set instead of read-modify-write race

P2 fixes:
- DesktopThreadFilter: LinkedHashSet for O(1) containment checks,
  down from O(R²) with MutableList
- consumeContactList: createdAt guard ensures newer events always win
- consumeEvent error logging: include event ID and relay URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-23 09:16:51 +02:00
parent c598c6135c
commit 4f5665a2bd
6 changed files with 75 additions and 50 deletions
@@ -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<K : Comparable<K>, V>(
private val maxSize: Int,
private val evictPercent: Float = 0.1f,
) {
private val inner = LargeCache<K, V>()
private val sizeCounter = AtomicInteger(0)
fun get(key: K): V? = inner.get(key)
@@ -47,7 +45,9 @@ class BoundedLargeCache<K : Comparable<K>, 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<K : Comparable<K>, 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<K> = inner.keys()
@@ -83,11 +96,15 @@ class BoundedLargeCache<K : Comparable<K>, V>(
fun count(consumer: CacheCollectors.BiFilter<K, V>): 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()
}
}
}
}
}
@@ -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
}
@@ -94,19 +94,20 @@ class DesktopThreadFilter(
override fun feed(): List<Note> {
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<Note>()
seen.add(root)
collectReplies(root, seen)
return seen.sortedWith(compareBy { it.createdAt() ?: 0 })
}
private fun collectReplies(
note: Note,
result: MutableList<Note>,
seen: LinkedHashSet<Note>,
) {
for (reply in note.replies) {
if (reply !in result) {
result.add(reply)
collectReplies(reply, result)
if (seen.add(reply)) {
collectReplies(reply, seen)
}
}
}
@@ -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 }
}
/**
@@ -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)
@@ -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