- Resolve LruCache vs LargeCache: use LargeCache (lock-free ConcurrentSkipListMap) with BoundedLargeCache wrapper for size enforcement on put() - Confirm LargeCache available on desktop via quartz jvmAndroid source set - Detail FeedNoteCard rewrite: Note model field mapping, 5 subscription removals - Fix filter examples to use filterIntoSet (LargeCache API) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
24 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: Desktop Cache-Centric Architecture for Navigation Persistence | feat | active | 2026-03-18 | docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md |
feat: Desktop Cache-Centric Architecture for Navigation Persistence
Overview
Migrate Amethyst Desktop from per-screen event state (EventCollectionState in remember {}) to Android's cache-centric pattern where DesktopLocalCache is the single source of truth. Feeds query the cache via FeedFilter, FeedViewModel subscribes to the cache event stream, and data survives navigation.
Three-phase incremental migration. Each phase is a standalone PR that improves UX.
Problem Statement
| Symptom | Root Cause |
|---|---|
| Back navigation shows loading spinners | EventCollectionState destroyed when composable leaves composition |
| Metadata re-fetched per screen | Screens call loadMetadataForPubkeys() instead of reading cache |
| Zap/reaction counts lost on navigate | Tracked in per-screen mutable state, not in Note model |
| Search results vanish on thread → back | Search screen's EventCollectionState is gone |
| Wasted network/relay resources | Same events re-fetched on every navigation; duplicate REQ filters |
Proposed Solution
Mirror Android Amethyst's architecture (see brainstorm: docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md):
Relays ──→ DesktopLocalCache.consume(event)
│
├──→ Store in maps (users, notes, addressableNotes)
├──→ Update Note relationships (replies, reactions, zaps)
└──→ DesktopCacheEventStream.emitNewNotes(note)
│
▼
FeedViewModel.collect { newNotes →
feedState.updateFeedWith(newNotes)
}
│
▼
Screen observes feedState.feedContent (Loading/Loaded/Empty)
Technical Approach
Phase 1: Store ALL Events in DesktopLocalCache
Goal: Make DesktopLocalCache the source of truth.
Branch: feat/desktop-cache-phase1
1.1 Switch cache backing to LargeCache with size enforcement
File: desktopApp/.../cache/DesktopLocalCache.kt
Replace ConcurrentHashMap with LargeCache from quartz — same backing store Android uses (ConcurrentSkipListMap on JVM), lock-free reads (CAS-based), rich query APIs (filterIntoSet, mapNotNull, range queries). Desktop keeps strong references (no WeakReference wrapper — that's Android-only LargeSoftCache for mobile memory pressure).
LargeCache chosen over LruCache because:
- Lock-free reads —
ConcurrentSkipListMapvssynchronizedon everyget(). Critical at 1000 events/sec. - Rich query API —
filterIntoSet,mapNotNullmatch Android's filter patterns exactly. - No snapshot overhead —
LruCache.snapshot()copies the entire map;LargeCacheiterates in-place.
Add size enforcement via a BoundedLargeCache wrapper that checks size on put() and evicts oldest entries when cap is exceeded:
class BoundedLargeCache<K : Comparable<K>, V>(
private val maxSize: Int,
private val evictPercent: Float = 0.1f, // Remove 10% when cap hit
) {
private val inner = LargeCache<K, V>()
fun get(key: K): V? = inner.get(key)
fun put(key: K, value: V) {
inner.put(key, value)
enforceSize()
}
fun getOrCreate(key: K, builder: (K) -> V): V = inner.getOrCreate(key, builder).also { enforceSize() }
fun remove(key: K): V? = inner.remove(key)
fun clear() = inner.clear()
fun size(): Int = inner.size()
fun values(): Iterable<V> = inner.values()
fun filterIntoSet(consumer: CacheCollectors.BiFilter<K, V>): Set<V> = inner.filterIntoSet(consumer)
// ... delegate other LargeCache methods as needed
private fun enforceSize() {
if (inner.size() > maxSize) {
val toRemove = (maxSize * evictPercent).toInt().coerceAtLeast(1)
// ConcurrentSkipListMap keys are sorted — first N keys are "oldest" by insertion order
val keys = inner.keys().take(toRemove)
keys.forEach { inner.remove(it) }
}
}
}
// Usage:
private val notes = BoundedLargeCache<HexKey, Note>(MAX_NOTES)
private val users = BoundedLargeCache<HexKey, User>(MAX_USERS)
private val addressableNotes = BoundedLargeCache<String, AddressableNote>(MAX_ADDRESSABLE)
companion object {
const val MAX_NOTES = 50_000 // ~100-150MB at ~2-3KB/note
const val MAX_USERS = 25_000 // ~25-50MB at ~1-2KB/user
const val MAX_ADDRESSABLE = 10_000
}
Note: ConcurrentSkipListMap keys are sorted, so keys().take(N) removes the lexicographically smallest hex keys — not strictly "oldest by time." For true time-based eviction, the enforceSize() could sort by note.event?.createdAt instead, but the simple key-based approach is cheaper and good enough (hex keys from Nostr events are effectively random, so eviction is approximately random).
1.2 Port consume methods from Android LocalCache
File: desktopApp/.../cache/DesktopLocalCache.kt
Start with 4 new event kinds (kind 9734 required for zap processing). Add remaining kinds per-screen in Phase 3.
| Kind | Event Type | Phase | Notes |
|---|---|---|---|
| 0 | MetadataEvent |
Done | Already exists (consumeMetadata) |
| 1 | TextNoteEvent |
1 | Core feed content |
| 7 | ReactionEvent |
1 | Reaction counts on Note |
| 9734 | LnZapRequestEvent |
1 | Required before kind 9735 can process |
| 9735 | LnZapEvent |
1 | Zap counts on Note |
Each consume method follows Android's pattern. Use event.tagsWithoutCitations() for reply parsing (handles both NIP-10 marked and legacy positional tags, excluding inline nostr: citations):
fun consume(event: TextNoteEvent, relay: NormalizedRelayUrl?): Boolean {
val note = checkGetOrCreateNote(event.id) ?: return false
if (note.event != null) return false // already have it
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addReply(note) }
refreshObservers(note)
return true
}
For reactions, check both e-tags and a-tags (reactions to addressable events like articles):
fun consume(event: ReactionEvent, relay: NormalizedRelayUrl?): Boolean {
val note = checkGetOrCreateNote(event.id) ?: return false
if (note.event != null) return false
val author = getOrCreateUser(event.pubKey)
val reactedTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, reactedTo)
reactedTo.forEach { it.addReaction(note) }
refreshObservers(note)
return true
}
1.3 Bridge relay callbacks to cache consumption
Problem: Relay onEvent callbacks are non-suspend. emitNewNotes() is suspend.
Solution: Use BasicBundledInsert from commons (already exists, battle-tested in Android) with 250ms delay (snappier than Android's 1000ms — desktop has mains power, users expect faster updates).
File: desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt
private val eventBundler = BasicBundledInsert<Note>(
delay = 250, // 250ms for desktop (Android uses 1000ms to save battery)
dispatcher = Dispatchers.IO,
scope = scope,
)
fun consumeEvent(event: Event, relay: NormalizedRelayUrl?) {
scope.launch(Dispatchers.IO) {
val consumed = localCache.consume(event, relay)
if (consumed) {
val note = localCache.getNoteIfExists(event.id) as? Note ?: return@launch
eventBundler.invalidateList(note) { batch ->
localCache.eventStream.emitNewNotes(batch)
}
}
}
}
Keep per-screen rememberSubscription — just change the onEvent callback to route through cache. Per-screen subscriptions handle lifecycle automatically (auto-cleanup on navigate away).
// Before (per-screen state):
onEvent = { event, _, _, _ -> eventState.addItem(event) }
// After (routes to cache):
onEvent = { event, _, relay, _ -> coordinator.consumeEvent(event, relay) }
1.4 Fix SharedFlow configuration
Problem: MutableSharedFlow(replay = 0) with no buffer drops events and blocks emitters on slow collectors. Android uses extraBufferCapacity=100, DROP_OLDEST.
File: desktopApp/.../cache/DesktopLocalCache.kt (DesktopCacheEventStream)
class DesktopCacheEventStream : ICacheEventStream {
private val _newEventBundles = MutableSharedFlow<Set<Note>>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val _deletedEventBundles = MutableSharedFlow<Set<Note>>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// ...
}
Dropped emissions are fine — events are already in the cache. The flow signals "something changed," not "here is the data."
1.5 Set JVM memory limit
File: desktopApp/build.gradle.kts
compose.desktop.application {
jvmArgs += "-Xmx2g"
}
Size enforcement is handled by BoundedLargeCache (section 1.1). -Xmx2g is a safety net for the JVM heap overall.
Eviction safety: Feed lists hold strong references to Note objects. When BoundedLargeCache evicts a key, the Note object survives in the feed list (GC won't collect it). On next FeedFilter.feed() refresh, the evicted note won't appear — acceptable (feed shows most recent N items). If a user clicks an evicted note, checkGetOrCreateNote(id) creates a shell Note that triggers a relay re-fetch.
1.6 Add cache clear on logout
File: desktopApp/.../account/AccountManager.kt
Cancel coordinator scope BEFORE clearing cache to prevent race between in-flight consume() calls and clear().
fun logout() {
coordinator.clear() // Stop subscriptions first
localCache.clear() // Then clear cache
}
Phase 1 Acceptance Criteria
DesktopLocalCachebacked byBoundedLargeCachewith size caps (50k notes, 25k users, 10k addressable)- Consume methods for kinds 1, 7, 9734, 9735
- Relay
onEventcallbacks route throughcoordinator.consumeEvent() BasicBundledInsert(250ms)batches events beforeemitNewNotes()DesktopCacheEventStreamhasextraBufferCapacity = 64, DROP_OLDEST-Xmx2gJVM arg set- Cache cleared on logout (coordinator first, then cache)
createGlobalFeedSubscription/createFollowingFeedSubscriptionconfirmed routing throughconsumeEvent./gradlew :desktopApp:compileKotlinpasses./gradlew spotlessApplyclean
Phase 2: Create Desktop FeedFilters
Goal: Query cache instead of holding per-screen event lists.
Branch: feat/desktop-cache-phase2
2.1 Desktop feed filters
Directory: desktopApp/.../feeds/
| Filter | Query Strategy | Base Class | Limit |
|---|---|---|---|
DesktopGlobalFeedFilter |
notes.filterIntoSet { kind == 1 } |
AdditiveFeedFilter<Note> |
2500 |
DesktopFollowingFeedFilter |
Same + author in account's follow list | AdditiveFeedFilter<Note> |
2500 |
DesktopThreadFilter(noteId) |
Root note + Note.replies graph walk |
FeedFilter<Note> |
unlimited |
DesktopProfileFeedFilter(pubkey) |
notes.filterIntoSet { author == pubkey } |
AdditiveFeedFilter<Note> |
1000 |
DesktopBookmarkFeedFilter(ids) |
Direct lookup by ID set | FeedFilter<Note> |
2500 |
DesktopReadsFeedFilter |
notes.filterIntoSet { kind == 30023 } |
AdditiveFeedFilter<Note> |
500 |
DesktopNotificationFeedFilter |
Events tagging logged-in user (reactions, zaps, replies, reposts) | AdditiveFeedFilter<Note> |
2500 |
DesktopSearchFeedFilter(query) |
Cache search + relay search results stored in cache | AdditiveFeedFilter<Note> |
500 |
Limits are ~5x Android's (desktop has more screen space and RAM). Filters use BoundedLargeCache.filterIntoSet — same API as Android's LocalCache.
The initial feed() scan runs only once on first load or feedKey() change. After that, updateListWith() uses applyFilter() + sort() incrementally — O(batch_size) not O(cache_size).
Following filter reads followed pubkeys from account state (same pattern as Android's HomeConversationsFeedFilter which reads account.liveHomeFollowLists).
Notification filter mirrors Android's NotificationFeedFilter: filters for events that tag the logged-in user across relevant kinds (kind 1, 6, 7, 9735), with mute list support. Simplified from Android's 20+ kinds to the kinds desktop actually displays.
class DesktopGlobalFeedFilter(
private val cache: DesktopLocalCache,
) : AdditiveFeedFilter<Note>() {
override fun feed(): List<Note> =
cache.notes.filterIntoSet { _, note -> note.event?.kind == 1 }
.sortedByDescending { it.event?.createdAt ?: 0 }
.take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems.filter { it.event?.kind == 1 }.toSet()
override fun sort(items: Set<Note>): List<Note> =
items.sortedByDescending { it.event?.createdAt ?: 0 }.take(limit())
override fun limit(): Int = 2500
}
Phase 2 Acceptance Criteria
- All 8 feed filters implemented and compile
applyFilter()andsort()work for incremental updates- Notification filter correctly identifies events tagging logged-in user
- Following filter reads follow list from account state
- Unit tests for each filter with mock cache data
./gradlew :desktopApp:compileKotlinpasses
Phase 3: Migrate Screens to FeedViewModel
Goal: Replace EventCollectionState with FeedViewModel pattern across all screens.
Branch: feat/desktop-cache-phase3
3.1 Create DesktopFeedViewModel
FeedViewModel.init in commons only sets up stream collectors — it doesn't load existing cache data. Navigation back would show Loading forever until a new relay event arrives. Fix by calling refreshSuspended() on init. Consider upstreaming to base FeedViewModel in commons.
File: desktopApp/.../viewmodels/DesktopFeedViewModel.kt
class DesktopFeedViewModel(
filter: FeedFilter<Note>,
cacheProvider: ICacheProvider,
) : FeedViewModel(filter, cacheProvider) {
init {
viewModelScope.launch(Dispatchers.IO) {
feedState.refreshSuspended()
}
}
}
3.2 ViewModel lifecycle management
Singleton feeds — created in Main.kt alongside other app-level state (relayManager, localCache, accountState). Standard Kotlin JVM pattern: create at app startup, pass down as parameters.
File: desktopApp/.../Main.kt
// App-level state (created once)
val localCache = DesktopLocalCache()
val coordinator = DesktopRelaySubscriptionsCoordinator(localCache, ...)
// Singleton ViewModels (created once, survive navigation)
val globalFeedVM = DesktopFeedViewModel(DesktopGlobalFeedFilter(localCache), localCache)
val followingFeedVM = DesktopFeedViewModel(DesktopFollowingFeedFilter(localCache, account), localCache)
val readsFeedVM = DesktopFeedViewModel(DesktopReadsFeedFilter(localCache), localCache)
val notificationsFeedVM = DesktopFeedViewModel(DesktopNotificationFeedFilter(localCache, account), localCache)
Passed to screens as parameters (not CompositionLocal).
Parameterized feeds — use remember(key) in Compose. Data survives navigation via the cache, not the ViewModel. New VMs query cache on creation via init { refreshSuspended() } for instant results.
@Composable
fun ThreadScreen(noteId: String, cache: DesktopLocalCache, ...) {
val vm = remember(noteId) {
DesktopFeedViewModel(DesktopThreadFilter(noteId, cache), cache)
}
DisposableEffect(vm) {
onDispose { vm.clear() } // Cancel viewModelScope
}
// ...
}
3.3 Per-screen subscriptions (unchanged)
Keep rememberSubscription in screens — it handles lifecycle automatically. Just change callbacks to route through cache.
rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) {
when (feedMode) {
FeedMode.GLOBAL -> createGlobalFeedSubscription(
relays = configuredRelays,
onEvent = { event, _, relay, _ ->
coordinator.consumeEvent(event, relay)
},
onEose = { _, _ -> eoseReceivedCount++ },
)
// ...
}
}
3.4 Migrate screens with per-screen consume methods
Migration order and consume methods needed per screen:
| Screen | VM Type | Consume Kinds to Add | Notes |
|---|---|---|---|
| FeedScreen | Singleton (global + following) | 3 (ContactList), 6 (Repost) | Includes FeedNoteCard rewrite |
| ThreadScreen | Parameterized (noteId) | 5 (Deletion) | Deleted note indicators |
| UserProfileScreen | Parameterized (pubkey) | — | Uses existing kinds |
| SearchResultsList | Parameterized (query) | — | Uses DesktopSearchFeedFilter |
| BookmarksScreen | Singleton | 30078 (BookmarkList) | Bookmark state from events |
| ReadsScreen | Singleton | 30023 (LongTextNote) | Articles feed |
| NotificationsScreen | Singleton | — | Uses DesktopNotificationFeedFilter |
FeedNoteCard rewrite — done alongside FeedScreen migration (first screen). All subsequent screen migrations benefit.
Current FeedNoteCard takes raw Event + per-screen counts:
// CURRENT: 6 per-screen state params
FeedNoteCard(event, ..., zapReceipts, reactionCount, replyCount, repostCount, ...)
New FeedNoteCard takes Note from cache — reads counts directly from model:
// NEW: Note replaces all per-screen count params
FeedNoteCard(note, ...) // inside: note.zaps.size, note.countReactions(), note.replies.size, note.boosts.size
Field mapping (per-screen state → Note model):
| Per-Screen State Map | Note Model Replacement |
|---|---|
zapsByEvent[id] → List<ZapReceipt> |
note.zaps → Map<Note, Note?> |
zapReceipts.sumOf { it.amountSats } |
note.zapsAmount (BigDecimal) |
reactionIdsByEvent[id] → count |
note.countReactions() |
replyIdsByEvent[id] → count |
note.replies.size |
repostIdsByEvent[id] → count |
note.boosts.size |
FeedScreen subscriptions removed (5 subscriptions, ~130 lines):
createZapsSubscription+zapsByEventstate mapcreateReactionsSubscription+reactionIdsByEventstate mapcreateRepliesSubscription+replyIdsByEventstate mapcreateRepostsSubscription+repostIdsByEventstate mapcreateBatchMetadataSubscriptionfor zap senders
These are replaced by cache.consume() which populates Note model relationships automatically.
Per-screen migration removes:
val eventState = remember { EventCollectionState<Event>(...) }- Per-screen
zapsByEvent,reactionIdsByEvent,replyIdsByEvent,repostIdsByEventmutable state maps - 5 per-screen subscription handlers (zaps, reactions, replies, reposts, metadata)
Per-screen migration adds:
val feedState by viewModel.feedState.feedContent.collectAsState()- Route
onEventtocoordinator.consumeEvent() - Always use
keyinitems():items(notes.list, key = { it.idHex })
when (val state = feedState) {
is FeedState.Loading -> LoadingState("Loading notes...")
is FeedState.Empty -> EmptyState(...)
is FeedState.Loaded -> {
val notes by state.feed.collectAsState()
LazyColumn {
items(notes.list, key = { it.idHex }) { note ->
FeedNoteCard(note = note, ...)
}
}
}
is FeedState.FeedError -> ErrorState(state.errorMessage)
}
Phase 3 Acceptance Criteria
DesktopFeedViewModelloads cache data on creation (initialrefreshSuspended())- Singleton ViewModels created in
Main.ktfor global/following/reads/notifications remember(key)+DisposableEffectfor parameterized feeds (thread, profile, search)FeedNoteCardrewritten to read fromNotemodel (done with FeedScreen migration)- Per-screen subscriptions route through
consumeEvent() - Consume methods added for kinds 3, 5, 6, 30023, 30078
- All 9 screens migrated: Feed, Thread, Profile, Search, Bookmarks, Reads, Notifications
- Per-screen zap/reaction/reply state removed (stored in Note model)
- Navigation back shows instant data (no loading spinner)
./gradlew :desktopApp:compileKotlinpasses./gradlew spotlessApplyclean- Manual test: Feed → Thread → Back → data persists
- Manual test: Search → Thread → Back → search results preserved
System-Wide Impact
Interaction Graph
User navigates to Feed
→ FeedScreen reads globalFeedViewModel.feedState
→ FeedContentState queries DesktopGlobalFeedFilter.feed()
→ Filter calls cache.notes.filterIntoSet { kind==1 }
→ Returns cached Note objects
Relay sends new event
→ OkHttp callback → coordinator.consumeEvent(event, relay)
→ scope.launch(IO) { localCache.consume(event) }
→ Note created/updated in cache
→ BasicBundledInsert(250ms) batches notes
→ eventStream.emitNewNotes(batch)
→ FeedViewModel.collect { feedState.updateFeedWith(notes) }
→ Compose recomposes
User navigates away and back
→ Singleton VM: feedState already Loaded → instant render
→ Parameterized VM: new VM created, init { refreshSuspended() } loads from cache → instant render
Error Propagation
| Error | Source | Handling |
|---|---|---|
| Relay disconnect | OkHttp | Coordinator reconnects, no cache impact |
| consume() throws | Cache | Caught in consumer coroutine, logged, event skipped |
| emitNewNotes() buffer full | SharedFlow | DROP_OLDEST — events already in cache |
| Filter query on empty cache | FeedFilter | Returns empty list → FeedState.Empty |
State Lifecycle Risks
| Risk | Mitigation |
|---|---|
| Stale data after logout | coordinator.clear() then localCache.clear() |
| Mixed-account data | ViewModels cleared + cache cleared on account switch |
| Subscription leak on app exit | Coordinator.clear() in shutdown hook |
| Cache memory pressure | -Xmx2g + BoundedLargeCache caps (50k notes, 25k users) |
Dependencies & Prerequisites
| Dependency | Status | Needed For |
|---|---|---|
LargeCache (quartz) |
✅ In quartz jvmAndroid — ConcurrentSkipListMap, lock-free |
Phase 1 |
BasicBundledInsert (commons) |
✅ In commons BundledUpdate.kt |
Phase 1 |
ICacheProvider / ICacheEventStream |
✅ In commons | Phase 1 |
Note.loadEvent(), addReply(), addReaction(), addZap() |
✅ In commons | Phase 1 |
FeedFilter<T> / AdditiveFeedFilter<T> |
✅ In commons | Phase 2 |
FeedViewModel / FeedContentState |
✅ In commons | Phase 3 |
kotlinx-coroutines-swing |
✅ In desktopApp deps | Dispatchers.Main |
Success Metrics
| Metric | Before | After |
|---|---|---|
| Back navigation time | 2-5s (full reload) | <100ms (cache hit) |
| Metadata re-fetch on navigate | Every screen | Never (cached) |
| Zap/reaction counts on back | Lost | Preserved |
Future Improvements (Deferred)
| Improvement | Trigger |
|---|---|
| Secondary indexes (by kind, by author) | feed() scan >50ms |
| Disk persistence (SQLite) | User requests cross-session persistence |
| Centralized subscription coordinator | Multiple screens need same relay filter |
Sources & References
- Origin: docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md
commons/.../ui/feeds/FeedFilter.kt,AdditiveFeedFilter.kt,FeedContentState.ktcommons/.../viewmodels/FeedViewModel.ktcommons/.../model/cache/ICacheProvider.kt,ICacheEventStream.ktcommons/.../model/Note.kt—loadEvent(),addReply(), reactions, zapsdesktopApp/.../cache/DesktopLocalCache.ktamethyst/.../model/LocalCache.kt— Android referencedocs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md— subscription stability