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
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.cache.CacheCollectors import com.vitorpamplona.quartz.utils.cache.CacheCollectors
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
@@ -52,7 +52,7 @@ class ThreadAssembler(
?.getOrNull(1) ?.getOrNull(1)
if (markedAsRoot != null) { if (markedAsRoot != null) {
// Check to see if there is an error in the tag and the root has replies // 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) { if (rootNote?.replyTo?.isEmpty() == true) {
return cache.checkGetOrCreateNote(markedAsRoot) return cache.checkGetOrCreateNote(markedAsRoot)
} }
@@ -57,7 +57,7 @@ interface ICacheProvider {
* @param pubkey The user's public key in hex format * @param pubkey The user's public key in hex format
* @return The User if exists in cache, null otherwise * @return The User if exists in cache, null otherwise
*/ */
fun getUserIfExists(pubkey: HexKey): Any? fun getUserIfExists(pubkey: HexKey): User?
/** /**
* Counts users matching a predicate. * Counts users matching a predicate.
@@ -75,7 +75,7 @@ interface ICacheProvider {
* @param hexKey The note's ID in hex format * @param hexKey The note's ID in hex format
* @return The Note if exists in cache, null otherwise * @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. * Gets an existing Note or creates a new one if it doesn't exist.
@@ -123,7 +123,7 @@ interface ICacheProvider {
fun findUsersStartingWith( fun findUsersStartingWith(
prefix: String, prefix: String,
limit: Int = 50, limit: Int = 50,
): List<Any> = emptyList() ): List<User> = emptyList()
/** /**
* Gets or creates a User by public key hex. * 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 * @param pubkey The user's public key in hex format
* @return The User (existing or newly created) * @return The User (existing or newly created)
*/ */
fun getOrCreateUser(pubkey: HexKey): Any? fun getOrCreateUser(pubkey: HexKey): User?
fun justConsumeMyOwnEvent(event: Event): Boolean fun justConsumeMyOwnEvent(event: Event): Boolean
} }
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note 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.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -95,7 +94,7 @@ class ChatNewMessageState(
if (currentRoom != null) { if (currentRoom != null) {
_recipientsMissingDmRelays.value = _recipientsMissingDmRelays.value =
currentRoom.users.any { hexKey -> currentRoom.users.any { hexKey ->
val user = cache.getOrCreateUser(hexKey) as? User val user = cache.getOrCreateUser(hexKey)
user?.dmInboxRelays().isNullOrEmpty() user?.dmInboxRelays().isNullOrEmpty()
} }
} else { } else {
@@ -141,7 +140,7 @@ class ChatNewMessageState(
) { ) {
val pTags = val pTags =
room.users.mapNotNull { hexKey -> room.users.mapNotNull { hexKey ->
(cache.getOrCreateUser(hexKey) as? User)?.toPTag() cache.getOrCreateUser(hexKey)?.toPTag()
} }
val replyHint = _replyTo.value?.toEventHint<BaseDMGroupEvent>() val replyHint = _replyTo.value?.toEventHint<BaseDMGroupEvent>()
@@ -88,8 +88,7 @@ class SearchBarState(
.debounce(debounceMs) .debounce(debounceMs)
.onEach { query -> .onEach { query ->
if (query.length >= 2 && _bech32Results.value.isEmpty()) { if (query.length >= 2 && _bech32Results.value.isEmpty()) {
@Suppress("UNCHECKED_CAST") _cachedUserResults.value = cache.findUsersStartingWith(query, 20)
_cachedUserResults.value = cache.findUsersStartingWith(query, 20) as List<User>
} else { } else {
_cachedUserResults.value = emptyList() _cachedUserResults.value = emptyList()
} }
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.commons.model.cache
import com.vitorpamplona.quartz.utils.cache.CacheOperations import com.vitorpamplona.quartz.utils.cache.CacheOperations
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@@ -59,7 +59,7 @@ class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
/** /**
* Puts an object into the cache with a specified key. * Puts an object into the cache with a specified key.
* The object is stored as a SoftReference. * The object is stored as a WeakReference.
* *
* @param key The key to associate with the object. * @param key The key to associate with the object.
* @param value The object to cache. * @param value The object to cache.
@@ -105,19 +105,16 @@ class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
/** /**
* Proactively cleans up the cache by removing entries whose weakly referenced * Proactively cleans up the cache by removing entries whose weakly referenced
* objects have been garbage collected. While `get` handles cleanup on access, * objects have been garbage collected. Single-pass iterator for efficiency.
* this method can be called periodically or when memory pressure is high.
*/ */
fun cleanUp() { fun cleanUp() {
val keysToRemove = mutableMapOf<K, WeakReference<V>>() val iter = cache.entries.iterator()
cache.forEach { key, softRef -> while (iter.hasNext()) {
if (softRef.get() == null) { val entry = iter.next()
keysToRemove.put(key, softRef) if (entry.value.get() == null) {
iter.remove()
} }
} }
keysToRemove.forEach { key, value ->
cache.remove(key, value)
}
} }
override fun forEach(consumer: BiConsumer<K, V>) { override fun forEach(consumer: BiConsumer<K, V>) {