Account switcher dropdown improvements: - Two-row display: Display Name on top, npub (middle-truncated) below e.g. 'Alice' / 'npub1abc...wxyz · Bunker' - Middle-truncation for npub: shows first 10 + last 6 chars - Resolves display names from DesktopLocalCache user metadata - Confirmation dialog also shows display name - npub-only (view-only) accounts now persist to encrypted storage (ensureCurrentAccountInStorage called in onLoginSuccess) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
26 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: WeakReference Cache + State Class Extraction | feat | active | 2026-03-24 | docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md |
feat: WeakReference Cache + State Class Extraction
Enhancement Summary
Deepened on: 2026-03-24 Agents used: kotlin-expert, kotlin-multiplatform, kotlin-coroutines, gradle-expert, architecture-strategist, performance-oracle, code-simplicity-reviewer, pattern-recognition-specialist
Key Improvements from Deepening
- Source set correction:
commons/jvmAndroidnotjvmMain— both KMP + Gradle agents confirmed - Massive simplification: Extract 5 State classes now (not 22) — only what Desktop needs today
- Per-feature repositories instead of single
IAccountSettings— matches existing commons pattern (EphemeralChatRepository,PublicChatListRepository) - Fix ICacheProvider
Any?returns directly —User/Notealready in commons, no new methods needed - DecryptionCaches are pure Kotlin — move as-is, no interfaces needed
- Performance fixes:
AtomicIntegersize tracker, single-passcleanUp(), debouncedfindUsersStartingWith - Heap monitoring:
MemoryMXBeancheck every 30s, cleanup at >75% or every 5min - 2 phases not 4 — cache swap + note pinning
Critical Issues Discovered
- Static
LocalCache.justConsumeMyOwnEvent()calls in State classes bypass injected cache param — must fix during extraction findUsersStartingWithbecomes O(n) per keystroke with no cap — needs debouncingsize()is O(n) on ConcurrentSkipListMap — add AtomicInteger tracker- Note.replies unbounded growth for pinned notes — pruning is NOT optional for long-running Desktop sessions
Overview
Replace BoundedLargeCache (strong refs, 50k cap, arbitrary eviction) with Android's LargeSoftCache (WeakReference-based, GC-driven). Extract 5 critical note-pinning State classes from amethyst/ to commons/ so Desktop gets the same retention logic. All in existing PR #1905 on branch feat/desktop-cache-v2.
(see brainstorm: docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md)
Problem Statement
Vitor's feedback on PR #1905: BoundedLargeCache causes notes to "just disappear" because eviction is by lowest hex key order — ignoring whether notes are displayed, replied-to, or part of a thread. Android solved this with LargeSoftCache<K, WeakReference<V>> where GC respects the reference graph.
Desktop's DesktopIAccount has zero State objects — it stubs everything. The State classes that pin important notes (bookmarks, follow list, mute list, relay lists, metadata) are Android-only.
Proposed Solution
Two-phase approach (simplified from original 4 phases):
- Phase 1: Cache swap — Move
LargeSoftCachetocommons/jvmAndroid, fixICacheProvidertypes, replaceBoundedLargeCache, add cleanup loop - Phase 2: Pin critical notes — Extract 5 State classes to
commons/commonMain, wire inDesktopIAccount
Remaining 17 State classes extracted incrementally as Desktop builds features that need them.
Technical Approach
Architecture
┌──────────────────────────────────────────────────────┐
│ LargeSoftCache<K, WeakRef<V>> │
│ (commons/jvmAndroid/ — shared) │
│ │
│ notes: LargeSoftCache<HexKey, Note> │
│ users: LargeSoftCache<HexKey, User> │
│ addressables: LargeSoftCache<String, AddressableNote>│
└───────────────────────┬──────────────────────────────┘
│ WeakRef (GC-managed)
┌───────────────────▼───────────────────┐
│ Note Reference Graph │
│ (strong refs: replies, reactions, │
│ zaps, boosts, replyTo, author) │
│ │
│ Clusters survive together. │
│ GC collects entire cluster when │
│ no external strong ref remains. │
└───────────────────┬───────────────────┘
│ strong refs (pinning)
┌───────────────────▼───────────────────┐
│ State Classes (commons/) │
│ (hold strong refs to important notes) │
│ │
│ BookmarkListState → bookmarkList note │
│ Kind3FollowListState → contact list │
│ MuteListState → mute list note │
│ Nip65RelayListState → relay list │
│ UserMetadataState → metadata note │
│ │
│ Live on Account (Android) or │
│ DesktopIAccount (Desktop) — both │
│ implement IAccount │
└───────────────────────────────────────┘
Implementation Phases
Phase 1: Cache Swap (~1 day)
Goal: Replace BoundedLargeCache with LargeSoftCache, fix ICacheProvider types, add cleanup.
1a. Fix ICacheProvider return types (prerequisite)
File: commons/src/commonMain/kotlin/.../model/cache/ICacheProvider.kt
User, Note, AddressableNote all live in commons/commonMain/ already. The Any? returns are a legacy artifact. Change directly:
interface ICacheProvider {
// Change these from Any? to proper types
fun getUserIfExists(pubkey: HexKey): User? // was Any?
fun getNoteIfExists(hexKey: HexKey): Note? // was Any?
fun getOrCreateUser(pubkey: HexKey): User? // was Any?
fun findUsersStartingWith(prefix: String, limit: Int = 50): List<User> // was List<Any>
// Already typed correctly — no change
fun checkGetOrCreateNote(hexKey: HexKey): Note?
fun getOrCreateAddressableNote(key: Address): AddressableNote
fun justConsumeMyOwnEvent(event: Event): Boolean
// ...
}
Callers that currently cast as? User just drop the cast. No breakage.
Research Insight (kotlin-expert): User/Note are already in commons — the Any? returns were from an earlier extraction. Safe to fix now.
1b. Move LargeSoftCache to commons/jvmAndroid
From: amethyst/src/main/java/.../model/LargeSoftCache.kt
To: commons/src/jvmAndroid/kotlin/.../model/cache/LargeSoftCache.kt
Research Insight (KMP + Gradle agents): Must be jvmAndroid, NOT jvmMain. The jvmAndroid source set in commons already exists (line 85-90 of commons/build.gradle.kts). Both jvmMain and androidMain depend on it. Zero Gradle changes needed. CacheOperations from quartz/jvmAndroid is visible transitively.
Changes:
- Move file, update package to
com.vitorpamplona.amethyst.commons.model.cache - Update Android's
LocalCache.ktimport (only file that imports it)
Performance improvements to LargeSoftCache:
class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
private val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
// NEW: O(1) approximate size tracking (performance-oracle recommendation)
private val _size = AtomicInteger(0)
fun approximateSize(): Int = _size.get()
fun put(key: K, value: V) {
val prev = cache.put(key, WeakReference(value))
if (prev == null) _size.incrementAndGet()
}
// IMPROVED: Single-pass cleanUp (performance-oracle: ~40% faster, zero alloc)
fun cleanUp() {
val iter = cache.entries.iterator()
while (iter.hasNext()) {
val entry = iter.next()
if (entry.value.get() == null) {
iter.remove()
_size.decrementAndGet()
}
}
}
// ... rest unchanged
}
1c. Replace BoundedLargeCache on Desktop
File: desktopApp/.../cache/DesktopLocalCache.kt
// Before
val users = BoundedLargeCache<HexKey, User>(MAX_USERS)
val notes = BoundedLargeCache<HexKey, Note>(MAX_NOTES)
val addressableNotes = BoundedLargeCache<String, AddressableNote>(MAX_ADDRESSABLE)
// After
val users = LargeSoftCache<HexKey, User>()
val notes = LargeSoftCache<HexKey, Note>()
val addressableNotes = LargeSoftCache<String, AddressableNote>()
Remove: BoundedLargeCache.kt, MAX_NOTES/MAX_USERS/MAX_ADDRESSABLE constants.
API differences:
BoundedLargeCache.values()→ useforEach+ collectBoundedLargeCache.count(predicate)→ available viaCacheOperationsuserCount()/noteCount()→ useapproximateSize()(O(1) vs O(n))
Performance fix for findUsersStartingWith:
// Before: O(n) per keystroke with no cap
// After: debounce at call site + yield during iteration
override fun findUsersStartingWith(prefix: String, limit: Int): List<User> {
val results = mutableListOf<User>()
users.forEach { _, user ->
if (results.size >= limit) return@forEach
if (user.anyNameStartsWith(prefix)) results.add(user)
}
return results
}
Call sites must debounce to 300ms. Acceptable because search already uses debounced state.
1d. Add Desktop cleanup loop
Research Insight (kotlin-coroutines): Inline in coordinator, no abstraction needed. Dispatchers.Default for CPU-bound work. Mutex.tryLock() if heap trigger added. yield() every 1000 items.
// In Coordinator or Main.kt — inline, no CacheCleanupService needed
private val cleanupScope = CoroutineScope(
SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, t ->
println("Cleanup failed, will retry: ${t.message}")
}
)
private val memoryBean = ManagementFactory.getMemoryMXBean()
private var lastCleanupTime = 0L
val cleanupJob = cleanupScope.launch {
delay(2.minutes) // Grace period for startup
while (isActive) {
delay(30.seconds) // Check frequently, clean infrequently
val heapPct = memoryBean.heapMemoryUsage.let { it.used.toDouble() / it.max }
val elapsed = System.currentTimeMillis() - lastCleanupTime
if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) {
val ops = listOf(
"cleanMemory" to { localCache.cleanMemory() },
"cleanObservers" to { cleanObserversWithYield() },
"pruneExpired" to { localCache.pruneExpiredEvents() },
"pruneReplaceables" to { localCache.prunePastVersionsOfReplaceables() },
)
ops.forEach { (name, op) ->
try { op() } catch (e: Exception) {
println("Cleanup $name failed: ${e.message}")
}
}
lastCleanupTime = System.currentTimeMillis()
}
}
}
private suspend fun cleanObserversWithYield() {
var count = 0
localCache.notes.forEach { _, note ->
note.clearFlow()
if (++count % 1000 == 0) yield()
}
}
Phase 1 Acceptance Criteria
ICacheProvidermethods return typedUser?/Note?(notAny?)LargeSoftCacheincommons/src/jvmAndroid/kotlin/.../model/cache/LargeSoftCachehasAtomicIntegersize tracker + single-passcleanUp()- Android's
LocalCacheimports from commons (one import change) - Desktop's
DesktopLocalCacheusesLargeSoftCache BoundedLargeCache.ktdeleted- Desktop cleanup loop: periodic + heap-pressure triggered
findUsersStartingWithdebounced at call sites./gradlew :commons:compileKotlinJvm :amethyst:compileDebugKotlin :desktopApp:compileKotlinpasses
Phase 2: Pin Critical Notes (~1-2 days)
Goal: Extract 5 essential State classes to commons so Desktop pins important notes.
Why only 5 (not 22)?
Research Insight (code-simplicity-reviewer): Desktop currently has zero State classes and zero UI for most of the 22 features. Only 5 State classes serve features Desktop has today:
| State Class | Why Desktop needs it now |
|---|---|
| BookmarkListState | Bookmarks screen exists |
| Kind3FollowListState | Follow list drives home feed |
| Nip65RelayListState | Relay list drives subscriptions |
| MuteListState | Content filtering |
| UserMetadataState | Profile display |
The other 17 (GeohashList, ProxyRelay, IndexerRelay, etc.) pin notes for features Desktop hasn't built yet. Extract them when needed.
2a. Per-feature repository interfaces (not IAccountSettings)
Research Insight (pattern-recognition-specialist): The existing commons State classes use per-feature repository interfaces, not a shared settings interface:
EphemeralChatRepository— 2 methods:ephemeralChatList(),updateEphemeralChatListTo()PublicChatListRepository— 2 methods:publicChatList(),updatePublicChatListTo()
Follow the same pattern. Each State class gets a narrow repository interface:
// commons/commonMain — one per State class that uses settings
interface Kind3FollowListRepository {
val backupContactList: ContactListEvent?
fun updateContactListTo(event: ContactListEvent)
}
interface MuteListRepository {
val backupMuteList: MuteListEvent?
fun updateMuteList(event: MuteListEvent)
}
interface Nip65RelayListRepository {
val backupNIP65RelayList: AdvertisedRelayListEvent?
fun updateNIP65RelayList(event: AdvertisedRelayListEvent)
}
// UserMetadataState — audit for specific settings usage
Android's AccountSettings implements all 5 interfaces. Desktop implements with stubs initially (no persistence yet), returning null for backup events.
Naming convention (pattern-recognition): I prefix for broad abstractions (IAccount, ICacheProvider). No prefix for narrow feature-scoped interfaces (Kind3FollowListRepository, MuteListRepository).
2b. Move DecryptionCaches as-is
Research Insight (kotlin-expert): DecryptionCaches are pure Kotlin wrappers around PrivateTagArrayEventCache<T> from quartz/commonMain. No interfaces needed, no Android deps:
// Already works in commons/commonMain — no changes needed
class MuteListDecryptionCache(val signer: NostrSigner) {
val cachedPrivateLists = PrivateTagArrayEventCache<MuteListEvent>(signer)
}
Only MuteListState uses a DecryptionCache among the initial 5 State classes. Move MuteListDecryptionCache alongside it.
2c. Fix static LocalCache calls
Research Insight (pattern-recognition + architecture): Several State classes call LocalCache.justConsumeMyOwnEvent(event) as a static singleton call, not through the injected cache parameter. Found in:
Kind3FollowListState(line 168)MuteListState(line 147)SearchRelayListState(line 108)
During extraction, change to cache.justConsumeMyOwnEvent(event) — the method already exists on ICacheProvider.
2d. Extraction order
- BookmarkListState — no settings, no decryptionCache, simplest. Verify imports: change
amethyst.model.Note→amethyst.commons.model.Note - Nip65RelayListState — needs
Nip65RelayListRepositoryinterface - Kind3FollowListState — needs
Kind3FollowListRepository, fix staticLocalCachecall - MuteListState — needs
MuteListRepository+ moveMuteListDecryptionCache, fix static call - UserMetadataState — audit settings usage, create repository interface
2e. Wire State classes on DesktopIAccount
class DesktopIAccount(
private val accountState: AccountState.LoggedIn,
val localCache: DesktopLocalCache,
// ...
) : IAccount {
// Stub repositories — no persistence yet, return null for backups
private val kind3Repo = object : Kind3FollowListRepository {
override val backupContactList: ContactListEvent? = null
override fun updateContactListTo(event: ContactListEvent) { /* no-op for now */ }
}
// ... similar stubs for other repos
val bookmarkList = BookmarkListState(signer, localCache, scope)
val kind3FollowList = Kind3FollowListState(signer, localCache, scope, kind3Repo)
val nip65RelayList = Nip65RelayListState(signer, localCache, scope, nip65Repo)
val muteList = MuteListState(signer, localCache, MuteListDecryptionCache(signer), scope, muteRepo)
val userMetadata = UserMetadataState(signer, localCache, scope, metadataRepo)
}
Phase 2 Acceptance Criteria
- 5 State classes in
commons/commonMain/ - Per-feature repository interfaces (not single IAccountSettings)
MuteListDecryptionCachemoved alongsideMuteListState- All static
LocalCache.calls changed tocache.(injected) - Android's
Accountimports State classes from commons (no behavior change) - Desktop's
DesktopIAccountcreates 5 State objects with stub repos - Pinned AddressableNotes survive GC on Desktop
@Stable/@Immutableannotations compile on all targets./gradlew spotlessApply+ all modules compile
System-Wide Impact
Interaction Graph
Event arrives from relay
→ Coordinator.consumeEvent()
→ DesktopLocalCache.consume()
→ notes.getOrCreate(id) [LargeSoftCache — creates WeakRef]
→ Note populated with event, author, relationships
→ Note.addReply/addReaction/addZap [strong refs to related notes]
→ eventStream.emit()
→ FeedViewModels receive update
State class created on DesktopIAccount
→ State.init { cache.getOrCreateAddressableNote(address) }
→ AddressableNote pinned via strong ref on State object
→ Note's reference graph (replies, reactions) also pinned transitively
→ Cluster survives GC
Periodic cleanup (every 5 min or heap > 75%)
→ LargeSoftCache.cleanUp() sweeps stale WeakRef entries (single-pass)
→ cleanObservers() clears unused NoteFlowSets (yield every 1000)
→ pruneExpiredEvents() removes NIP-40 expired notes
→ prunePastVersionsOfReplaceables() keeps only latest replaceable
Error Propagation
- getOrCreate race: Two threads create same Note →
putIfAbsentensures one wins. Note constructor is pure (just storesidHex), no side effects. Acceptable. - GC during render: Compose holds strong ref during composition. Cache eviction can't crash.
- State class missing: If a State class isn't created, that note type has no pin → GC can collect. Mitigated by creating all 5 critical ones at init.
State Lifecycle Risks
- Note.replies unbounded growth: Pinned notes keep all replies alive via strong refs. For long-running Desktop sessions, this grows monotonically.
pruneRepliesAndReactionsis account-dependent — tracked as follow-up work (not deferred indefinitely). - NoteFlowSet leak:
cleanObservers()withyield()handles this. SharingStarted.Eagerlyon State classes: All 5 State classes usestateIn(scope, SharingStarted.Eagerly). Desktop has no process lifecycle to bound this. ConsiderWhileSubscribed(5000)for non-critical states when expanding beyond 5.- Replaceable event swap: AddressableNote stays pinned,
.eventswaps. FlowSet emits. No orphaned refs.
Performance Characteristics
| Operation | Before (BoundedLargeCache) | After (LargeSoftCache) |
|---|---|---|
size() |
O(1) via AtomicInteger | O(1) via AtomicInteger (new) |
get(key) |
O(log n), strong ref | O(log n), WeakRef deref |
forEach |
O(n), no GC checks | O(n), GC check per entry |
cleanUp() |
N/A (evict by key order) | O(n) single-pass iterator |
findUsersStartingWith |
O(n) capped at 25k | O(n) uncapped — debounce required |
| Eviction | Arbitrary (by hex key) | GC-driven (respects refs) |
| Memory cap | Hard (50k/25k/10k) | None (GC-managed) |
Acceptance Criteria
Functional
- Desktop cache uses WeakReferences — no hard size limits
- Notes only disappear when nothing references them (not arbitrarily)
- Bookmarks, contact list, mute list, relay lists, metadata pinned on Desktop
- Navigation persistence maintained (existing behavior)
- Android behavior unchanged (same code, new import path)
Non-Functional
BoundedLargeCachedeleted- 5 critical State classes in commons
- Periodic + heap-triggered cleanup on Desktop
LargeSoftCacheshared fromcommons/jvmAndroid/ICacheProviderreturns typedUser?/Note?./gradlew spotlessApply+ compile on all modules
Dependencies & Risks
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
Static LocalCache. calls in State classes |
High | High | Audit each class. Found in Kind3FollowList, MuteList, SearchRelay. Change to cache. |
findUsersStartingWith O(n) jank |
High | Medium | Debounce 300ms at call sites. Already debounced in search UI. |
size() O(n) in logging paths |
Medium | Low | AtomicInteger tracker on LargeSoftCache. Use approximateSize(). |
| Note.replies unbounded growth | Medium | Medium | Follow-up: implement non-account-dependent reply cap (e.g., 500 per note) |
| Import path changes break Android | Low | Medium | Only LocalCache.kt changes import. One-line diff. |
BookmarkListState imports amethyst.model.Note (wrong package) |
Low | Low | Change to amethyst.commons.model.Note during extraction |
Future Work (tracked, not deferred indefinitely)
- Extract remaining 17 State classes — as Desktop builds features that need them
pruneRepliesAndReactions— non-account-dependent reply/reaction cap (e.g., 500 per note) to bound memory for popular threads- Desktop persistence — implement real
Kind3FollowListRepositoryetc. with disk-backed storage SharingStarted.WhileSubscribed— for non-critical State classes when expanding beyond 5
Success Metrics
- BoundedLargeCache eviction bugs → eliminated (WeakRef model)
- Desktop note retention → matches Android behavior for 5 critical types
- Android behavior → unchanged (regression-free)
- Desktop memory stability → heap monitoring confirms GC collects unpinned notes
Sources & References
Origin
- Brainstorm: docs/brainstorms/2026-03-24-weakref-cache-architecture-brainstorm.md — Key decisions: LargeSoftCache shared, extract State classes, same pinning as Android, extensible for Desktop-specific pins later, one PR in existing #1905
Internal References
- Prior clean-cache plan:
docs/plans/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md - Cache architecture brainstorm:
docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md - Android LargeSoftCache:
amethyst/src/main/java/.../model/LargeSoftCache.kt - Android LocalCache:
amethyst/src/main/java/.../model/LocalCache.kt - Android Account (State objects):
amethyst/src/main/java/.../model/Account.kt - Android MemoryTrimmingService:
amethyst/src/main/java/.../service/eventCache/MemoryTrimmingService.kt - Desktop DesktopLocalCache:
desktopApp/.../cache/DesktopLocalCache.kt - Desktop BoundedLargeCache:
desktopApp/.../cache/BoundedLargeCache.kt - Desktop DesktopIAccount:
desktopApp/.../model/DesktopIAccount.kt - ICacheProvider:
commons/.../model/cache/ICacheProvider.kt - CacheOperations:
quartz/src/jvmAndroid/.../utils/cache/CacheOperations.kt - Note model:
commons/.../model/Note.kt - Existing pattern:
EphemeralChatRepository/PublicChatListRepositoryin commons
Research Insights Applied
- kotlin-expert: DecryptionCaches are pure Kotlin (move as-is), fix
Any?returns directly, normalize static LocalCache calls, only 3 files use@Stable/@Immutable - kotlin-multiplatform:
commons/jvmAndroid(notjvmMain), zero Gradle changes,CacheOperationsvisible transitively - kotlin-coroutines:
SupervisorJob+Dispatchers.Default,Mutex.tryLock()for heap trigger,yield()every 1000 items, 2-min startup grace period - gradle-expert: Zero build.gradle.kts changes needed, no circular deps introduced
- architecture-strategist: Fix ICacheProvider types as Phase 0 prerequisite,
pruneRepliesAndReactionsis NOT optional for Desktop, considerSharingStarted.WhileSubscribedfor non-critical states - performance-oracle:
AtomicIntegersize tracker, single-passcleanUp(),findUsersStartingWithO(n) jank needs debouncing, non-account-dependent reply cap needed - code-simplicity-reviewer: 5 State classes not 22, per-feature repos not IAccountSettings, inline cleanup not CacheCleanupService, 2 phases not 4
- pattern-recognition-specialist: Per-feature repository interfaces match
EphemeralChatRepositorypattern,Iprefix for broad abstractions only, staticLocalCache.calls are extraction blockers
Resolved Questions (from original plan)
- AccountSettings audit → Resolved: use per-feature repository interfaces, not single IAccountSettings. Each State class gets a narrow 2-method interface.
- DecryptionCache complexity → Resolved: pure Kotlin wrappers around quartz's
PrivateTagArrayEventCache. Move as-is, no interfaces. - ICacheProvider Any? methods → Resolved:
User/Notealready in commons. Change return types directly. Callers drop casts. - Account-dependent prune timing → Resolved: follow-up work, not deferred. Implement non-account-dependent reply cap (500 per note) as next step.