refactor(cache): move LargeSoftCache to commons/jvmAndroid, fix ICacheProvider types

- Move LargeSoftCache from amethyst/model to commons/jvmAndroid/model/cache
  so both Android and Desktop share the same WeakReference cache implementation
- Change ICacheProvider return types from Any? to proper types (User?, Note?)
  since these model classes already live in commons
- Remove unnecessary casts in ThreadAssembler, ChatNewMessageState, SearchBarState
- Improve LargeSoftCache.cleanUp() to use single-pass iterator (zero allocation)
- Update Android imports in LocalCache, LargeSoftCacheAddressExt, and test

Per Vitor's feedback on PR #1905: BoundedLargeCache evicts arbitrarily.
LargeSoftCache uses WeakReferences so GC respects the reference graph.
This commit is contained in:
nrobi144
2026-03-24 09:09:29 +02:00
parent 3b489fb3cb
commit 00b06a0e2a
8 changed files with 19 additions and 21 deletions
@@ -52,7 +52,7 @@ class ThreadAssembler(
?.getOrNull(1)
if (markedAsRoot != null) {
// Check to see if there is an error in the tag and the root has replies
val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note
val rootNote = cache.getNoteIfExists(markedAsRoot)
if (rootNote?.replyTo?.isEmpty() == true) {
return cache.checkGetOrCreateNote(markedAsRoot)
}
@@ -57,7 +57,7 @@ interface ICacheProvider {
* @param pubkey The user's public key in hex format
* @return The User if exists in cache, null otherwise
*/
fun getUserIfExists(pubkey: HexKey): Any?
fun getUserIfExists(pubkey: HexKey): User?
/**
* Counts users matching a predicate.
@@ -75,7 +75,7 @@ interface ICacheProvider {
* @param hexKey The note's ID in hex format
* @return The Note if exists in cache, null otherwise
*/
fun getNoteIfExists(hexKey: HexKey): Any?
fun getNoteIfExists(hexKey: HexKey): Note?
/**
* Gets an existing Note or creates a new one if it doesn't exist.
@@ -123,7 +123,7 @@ interface ICacheProvider {
fun findUsersStartingWith(
prefix: String,
limit: Int = 50,
): List<Any> = emptyList()
): List<User> = emptyList()
/**
* Gets or creates a User by public key hex.
@@ -132,7 +132,7 @@ interface ICacheProvider {
* @param pubkey The user's public key in hex format
* @return The User (existing or newly created)
*/
fun getOrCreateUser(pubkey: HexKey): Any?
fun getOrCreateUser(pubkey: HexKey): User?
fun justConsumeMyOwnEvent(event: Event): Boolean
}
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -95,7 +94,7 @@ class ChatNewMessageState(
if (currentRoom != null) {
_recipientsMissingDmRelays.value =
currentRoom.users.any { hexKey ->
val user = cache.getOrCreateUser(hexKey) as? User
val user = cache.getOrCreateUser(hexKey)
user?.dmInboxRelays().isNullOrEmpty()
}
} else {
@@ -141,7 +140,7 @@ class ChatNewMessageState(
) {
val pTags =
room.users.mapNotNull { hexKey ->
(cache.getOrCreateUser(hexKey) as? User)?.toPTag()
cache.getOrCreateUser(hexKey)?.toPTag()
}
val replyHint = _replyTo.value?.toEventHint<BaseDMGroupEvent>()
@@ -88,8 +88,7 @@ class SearchBarState(
.debounce(debounceMs)
.onEach { query ->
if (query.length >= 2 && _bech32Results.value.isEmpty()) {
@Suppress("UNCHECKED_CAST")
_cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List<User>
_cachedUserResults.value = cache.findUsersStartingWith(query, 20)
} else {
_cachedUserResults.value = emptyList()
}
@@ -0,0 +1,150 @@
/*
* 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.commons.model.cache
import com.vitorpamplona.quartz.utils.cache.CacheOperations
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentSkipListMap
import java.util.function.BiConsumer
class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
private val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
fun keys() = cache.keys
fun get(key: K): V? {
val softRef = cache.get(key) ?: return null
val value = softRef.get()
return if (value != null) {
value
} else {
cache.remove(key, softRef)
null
}
}
fun remove(key: K) = cache.remove(key)
fun removeIf(
key: K,
value: WeakReference<V>,
) = cache.remove(key, value)
override fun size() = cache.size
fun isEmpty() = cache.isEmpty()
fun clear() = cache.clear()
fun containsKey(key: K) = cache.containsKey(key)
/**
* Puts an object into the cache with a specified key.
* The object is stored as a WeakReference.
*
* @param key The key to associate with the object.
* @param value The object to cache.
*/
fun put(
key: K,
value: V,
) {
cache.put(key, WeakReference(value))
}
/**
* Retrieves an object from the cache using its key.
* Returns the object if it's still available (not garbage collected),
* otherwise returns null. If the object has been garbage collected,
* its entry is also removed from the cache.
*
* @param key The key of the object to retrieve.
* @return The cached object, or null if it's no longer available.
*/
fun getOrCreate(
key: K,
builder: (key: K) -> V,
): V {
val softRef = cache[key]
return if (softRef == null) {
val newObject = builder(key)
cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
} else {
val value = softRef.get()
if (value != null) {
value
} else {
// removes first to make sure the putIfAbsent works.
// another thread may put in between
cache.remove(key, softRef)
val newObject = builder(key)
cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
}
}
}
/**
* Proactively cleans up the cache by removing entries whose weakly referenced
* objects have been garbage collected. Single-pass iterator for efficiency.
*/
fun cleanUp() {
val iter = cache.entries.iterator()
while (iter.hasNext()) {
val entry = iter.next()
if (entry.value.get() == null) {
iter.remove()
}
}
}
override fun forEach(consumer: BiConsumer<K, V>) {
cache.forEach(BiConsumerWrapper(this, consumer))
}
override fun forEach(
from: K,
to: K,
consumer: BiConsumer<K, V>,
) {
cache
.subMap(from, true, to, true)
.forEach(BiConsumerWrapper(this, consumer))
}
class BiConsumerWrapper<K : Any, V : Any>(
val cache: LargeSoftCache<K, V>,
val inner: BiConsumer<K, V>,
) : BiConsumer<K, WeakReference<V>> {
override fun accept(
k: K,
ref: WeakReference<V>,
) {
val value = ref.get()
if (value == null) {
cache.removeIf(k, ref)
} else {
inner.accept(k, value)
}
}
}
}