--- title: "feat: WeakReference Cache + State Class Extraction" type: feat status: active date: 2026-03-24 origin: 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 1. **Source set correction:** `commons/jvmAndroid` not `jvmMain` — both KMP + Gradle agents confirmed 2. **Massive simplification:** Extract 5 State classes now (not 22) — only what Desktop needs today 3. **Per-feature repositories** instead of single `IAccountSettings` — matches existing commons pattern (`EphemeralChatRepository`, `PublicChatListRepository`) 4. **Fix ICacheProvider `Any?` returns directly** — `User`/`Note` already in commons, no new methods needed 5. **DecryptionCaches are pure Kotlin** — move as-is, no interfaces needed 6. **Performance fixes:** `AtomicInteger` size tracker, single-pass `cleanUp()`, debounced `findUsersStartingWith` 7. **Heap monitoring:** `MemoryMXBean` check every 30s, cleanup at >75% or every 5min 8. **2 phases not 4** — cache swap + note pinning ### Critical Issues Discovered 1. **Static `LocalCache.justConsumeMyOwnEvent()` calls** in State classes bypass injected cache param — must fix during extraction 2. **`findUsersStartingWith` becomes O(n) per keystroke** with no cap — needs debouncing 3. **`size()` is O(n)** on ConcurrentSkipListMap — add AtomicInteger tracker 4. **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>` 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): 1. **Phase 1: Cache swap** — Move `LargeSoftCache` to `commons/jvmAndroid`, fix `ICacheProvider` types, replace `BoundedLargeCache`, add cleanup loop 2. **Phase 2: Pin critical notes** — Extract 5 State classes to `commons/commonMain`, wire in `DesktopIAccount` Remaining 17 State classes extracted incrementally as Desktop builds features that need them. ## Technical Approach ### Architecture ``` ┌──────────────────────────────────────────────────────┐ │ LargeSoftCache> │ │ (commons/jvmAndroid/ — shared) │ │ │ │ notes: LargeSoftCache │ │ users: LargeSoftCache │ │ addressables: LargeSoftCache│ └───────────────────────┬──────────────────────────────┘ │ 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: ```kotlin 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 // was List // 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.kt` import (only file that imports it) **Performance improvements to LargeSoftCache:** ```kotlin class LargeSoftCache : CacheOperations { private val cache = ConcurrentSkipListMap>() // 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` ```kotlin // Before val users = BoundedLargeCache(MAX_USERS) val notes = BoundedLargeCache(MAX_NOTES) val addressableNotes = BoundedLargeCache(MAX_ADDRESSABLE) // After val users = LargeSoftCache() val notes = LargeSoftCache() val addressableNotes = LargeSoftCache() ``` **Remove:** `BoundedLargeCache.kt`, `MAX_NOTES`/`MAX_USERS`/`MAX_ADDRESSABLE` constants. **API differences:** - `BoundedLargeCache.values()` → use `forEach` + collect - `BoundedLargeCache.count(predicate)` → available via `CacheOperations` - `userCount()`/`noteCount()` → use `approximateSize()` (O(1) vs O(n)) **Performance fix for `findUsersStartingWith`:** ```kotlin // Before: O(n) per keystroke with no cap // After: debounce at call site + yield during iteration override fun findUsersStartingWith(prefix: String, limit: Int): List { val results = mutableListOf() 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. ```kotlin // 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 - [ ] `ICacheProvider` methods return typed `User?`/`Note?` (not `Any?`) - [ ] `LargeSoftCache` in `commons/src/jvmAndroid/kotlin/.../model/cache/` - [ ] `LargeSoftCache` has `AtomicInteger` size tracker + single-pass `cleanUp()` - [ ] Android's `LocalCache` imports from commons (one import change) - [ ] Desktop's `DesktopLocalCache` uses `LargeSoftCache` - [ ] `BoundedLargeCache.kt` deleted - [ ] Desktop cleanup loop: periodic + heap-pressure triggered - [ ] `findUsersStartingWith` debounced at call sites - [ ] `./gradlew :commons:compileKotlinJvm :amethyst:compileDebugKotlin :desktopApp:compileKotlin` passes --- #### 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: ```kotlin // 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` from `quartz/commonMain`. No interfaces needed, no Android deps: ```kotlin // Already works in commons/commonMain — no changes needed class MuteListDecryptionCache(val signer: NostrSigner) { val cachedPrivateLists = PrivateTagArrayEventCache(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 1. **BookmarkListState** — no settings, no decryptionCache, simplest. Verify imports: change `amethyst.model.Note` → `amethyst.commons.model.Note` 2. **Nip65RelayListState** — needs `Nip65RelayListRepository` interface 3. **Kind3FollowListState** — needs `Kind3FollowListRepository`, fix static `LocalCache` call 4. **MuteListState** — needs `MuteListRepository` + move `MuteListDecryptionCache`, fix static call 5. **UserMetadataState** — audit settings usage, create repository interface ##### 2e. Wire State classes on DesktopIAccount ```kotlin 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) - [ ] `MuteListDecryptionCache` moved alongside `MuteListState` - [ ] All static `LocalCache.` calls changed to `cache.` (injected) - [ ] Android's `Account` imports State classes from commons (no behavior change) - [ ] Desktop's `DesktopIAccount` creates 5 State objects with stub repos - [ ] Pinned AddressableNotes survive GC on Desktop - [ ] `@Stable`/`@Immutable` annotations 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 → `putIfAbsent` ensures one wins. Note constructor is pure (just stores `idHex`), 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. `pruneRepliesAndReactions` is account-dependent — tracked as follow-up work (not deferred indefinitely). - **NoteFlowSet leak:** `cleanObservers()` with `yield()` handles this. - **`SharingStarted.Eagerly` on State classes:** All 5 State classes use `stateIn(scope, SharingStarted.Eagerly)`. Desktop has no process lifecycle to bound this. Consider `WhileSubscribed(5000)` for non-critical states when expanding beyond 5. - **Replaceable event swap:** AddressableNote stays pinned, `.event` swaps. 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 - [ ] `BoundedLargeCache` deleted - [ ] 5 critical State classes in commons - [ ] Periodic + heap-triggered cleanup on Desktop - [ ] `LargeSoftCache` shared from `commons/jvmAndroid/` - [ ] `ICacheProvider` returns typed `User?`/`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) 1. **Extract remaining 17 State classes** — as Desktop builds features that need them 2. **`pruneRepliesAndReactions`** — non-account-dependent reply/reaction cap (e.g., 500 per note) to bound memory for popular threads 3. **Desktop persistence** — implement real `Kind3FollowListRepository` etc. with disk-backed storage 4. **`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](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` / `PublicChatListRepository` in 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` (not `jvmMain`), zero Gradle changes, `CacheOperations` visible 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, `pruneRepliesAndReactions` is NOT optional for Desktop, consider `SharingStarted.WhileSubscribed` for non-critical states - **performance-oracle:** `AtomicInteger` size tracker, single-pass `cleanUp()`, `findUsersStartingWith` O(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 `EphemeralChatRepository` pattern, `I` prefix for broad abstractions only, static `LocalCache.` calls are extraction blockers ### Resolved Questions (from original plan) 1. **AccountSettings audit** → Resolved: use per-feature repository interfaces, not single IAccountSettings. Each State class gets a narrow 2-method interface. 2. **DecryptionCache complexity** → Resolved: pure Kotlin wrappers around quartz's `PrivateTagArrayEventCache`. Move as-is, no interfaces. 3. **ICacheProvider Any? methods** → Resolved: `User`/`Note` already in commons. Change return types directly. Callers drop casts. 4. **Account-dependent prune timing** → Resolved: follow-up work, not deferred. Implement non-account-dependent reply cap (500 per note) as next step.