feat(cache): replace BoundedLargeCache with LargeSoftCache on Desktop

- DesktopLocalCache now uses LargeSoftCache (WeakReference-based, GC-driven)
  instead of BoundedLargeCache (strong refs, 50k cap, arbitrary eviction)
- Delete BoundedLargeCache.kt — no longer needed
- Rewrite findUsersStartingWith to use forEach instead of values()
- Remove BoundedLargeCache eviction tests (no longer applicable)
- Notes now only disappear when nothing references them, not arbitrarily

Per Vitor's feedback: "this maximum size approach might not work well,
as things will just disappear"
This commit is contained in:
nrobi144
2026-03-24 09:14:02 +02:00
parent 00b06a0e2a
commit 3dbbc039b3
3 changed files with 13 additions and 142 deletions
@@ -1,102 +0,0 @@
/*
* 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.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], entries are evicted by key order.
* Uses [LargeCache] (ConcurrentSkipListMap) for lock-free reads and rich query APIs.
*
* 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)
fun put(
key: K,
value: V,
) {
val existing = inner.get(key)
inner.put(key, value)
if (existing == null) sizeCounter.incrementAndGet()
enforceSize()
}
fun getOrCreate(
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? {
val removed = inner.remove(key)
if (removed != null) sizeCounter.decrementAndGet()
return removed
}
fun size(): Int = sizeCounter.get()
fun clear() {
inner.clear()
sizeCounter.set(0)
}
fun keys(): Set<K> = inner.keys()
fun values(): Iterable<V> = inner.values()
fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> = inner.filterIntoSet(consumer)
fun count(consumer: CacheCollectors.BiFilter<K, V>): Int = inner.count(consumer)
private fun enforceSize() {
val currentSize = sizeCounter.get()
if (currentSize > maxSize) {
val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1)
val keys = inner.keys().take(toRemove)
keys.forEach {
if (inner.remove(it) != null) {
sizeCounter.decrementAndGet()
}
}
}
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -63,9 +64,9 @@ import java.util.concurrent.ConcurrentHashMap
* Supports searching users by name prefix for the search functionality.
*/
class DesktopLocalCache : ICacheProvider {
val users = BoundedLargeCache<HexKey, User>(MAX_USERS)
val notes = BoundedLargeCache<HexKey, Note>(MAX_NOTES)
val addressableNotes = BoundedLargeCache<String, AddressableNote>(MAX_ADDRESSABLE)
val users = LargeSoftCache<HexKey, User>()
val notes = LargeSoftCache<HexKey, Note>()
val addressableNotes = LargeSoftCache<String, AddressableNote>()
private val deletedEvents = ConcurrentHashMap.newKeySet<HexKey>()
val eventStream = DesktopCacheEventStream()
@@ -75,9 +76,6 @@ class DesktopLocalCache : ICacheProvider {
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()
companion object {
const val MAX_NOTES = 50_000
const val MAX_USERS = 25_000
const val MAX_ADDRESSABLE = 10_000
}
val paymentTracker = NwcPaymentTracker()
@@ -114,10 +112,10 @@ class DesktopLocalCache : ICacheProvider {
)
// Search by name/displayName/nip05/lud16
return users
.values()
.filter { user ->
val metadata = user.metadataOrNull()
val results = mutableListOf<User>()
users.forEach { _, user ->
val metadata = user.metadataOrNull()
val matches =
if (metadata == null) {
user.pubkeyHex.startsWith(prefix, true) ||
user.pubkeyNpub().startsWith(prefix, true)
@@ -126,7 +124,10 @@ class DesktopLocalCache : ICacheProvider {
user.pubkeyHex.startsWith(prefix, true) ||
user.pubkeyNpub().startsWith(prefix, true)
}
}.sortedWith(
if (matches) results.add(user)
}
return results
.sortedWith(
compareBy(
{ it.metadataOrNull()?.anyNameStartsWith(dualCase) == false },
{ it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false },
@@ -496,35 +496,7 @@ class DesktopCachePipelineTest {
}
// -----------------------------------------------------------------------
// 8. BoundedLargeCache eviction
// -----------------------------------------------------------------------
@Test
fun `BoundedLargeCache evicts when over capacity`() {
val cache = BoundedLargeCache<String, String>(10, evictPercent = 0.5f)
repeat(15) { i ->
cache.put("key_${i.toString().padStart(3, '0')}", "value_$i")
}
assertTrue(cache.size() <= 10, "Cache should not exceed max size, got ${cache.size()}")
}
@Test
fun `BoundedLargeCache get returns null for evicted entries`() {
val cache = BoundedLargeCache<String, String>(5, evictPercent = 0.5f)
repeat(10) { i ->
cache.put("key_${i.toString().padStart(3, '0')}", "value_$i")
}
// Some early entries should have been evicted
val size = cache.size()
assertTrue(size <= 5, "Cache should be at or below max size")
}
// -----------------------------------------------------------------------
// 9. Additive filter incremental updates
// 8. Additive filter incremental updates
// -----------------------------------------------------------------------
@Test