feat(cache): add periodic memory cleanup for Desktop

- Add cleanMemory() to DesktopLocalCache (sweeps stale WeakRef entries)
- Add startCleanupLoop() to Coordinator with heap monitoring
  (MemoryMXBean, checks every 30s, cleans at >75% heap or every 5min)
- cleanObservers() clears unused NoteFlowSets on notes + addressables
- Try-catch per operation so one failure doesn't skip the rest
- 2-minute startup grace period before first cleanup
- Cleanup job cancelled on clear() (account switch / logout)
This commit is contained in:
nrobi144
2026-03-24 09:20:01 +02:00
parent 3dbbc039b3
commit cf6a74b162
4 changed files with 73 additions and 48 deletions
@@ -153,7 +153,6 @@ sealed class DesktopScreen {
) : DesktopScreen() ) : DesktopScreen()
data object Drafts : DesktopScreen() data object Drafts : DesktopScreen()
data object Settings : DesktopScreen() data object Settings : DesktopScreen()
} }
@@ -457,7 +456,7 @@ fun App(
RelayUrlNormalizer.normalizeOrNull(it) RelayUrlNormalizer.normalizeOrNull(it)
}.toSet(), }.toSet(),
localCache = localCache, localCache = localCache,
) ).also { it.startCleanupLoop() }
} }
// Clear cache and subscriptions on logout // Clear cache and subscriptions on logout
@@ -515,6 +515,14 @@ class DesktopLocalCache : ICacheProvider {
followingCounts[pubkey] = count followingCounts[pubkey] = count
} }
// ----- Memory Cleanup -----
fun cleanMemory() {
notes.cleanUp()
addressableNotes.cleanUp()
users.cleanUp()
}
// ----- Stats ----- // ----- Stats -----
fun userCount(): Int = users.size() fun userCount(): Int = users.size()
@@ -37,11 +37,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.lang.management.ManagementFactory
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/** /**
* Desktop-specific relay subscriptions coordinator. * Desktop-specific relay subscriptions coordinator.
@@ -356,5 +361,57 @@ class DesktopRelaySubscriptionsCoordinator(
unsubscribeFromDms() unsubscribeFromDms()
feedMetadata.clear() feedMetadata.clear()
rateLimiter.reset() rateLimiter.reset()
cleanupJob?.cancel()
}
// ----- Memory Cleanup -----
private val memoryBean = ManagementFactory.getMemoryMXBean()
private var lastCleanupTime = 0L
private var cleanupJob: Job? = null
/**
* Starts a periodic memory cleanup coroutine.
* Checks heap usage every 30s, runs cleanup at >75% heap or every 5 minutes.
*/
fun startCleanupLoop() {
cleanupJob =
scope.launch(Dispatchers.Default) {
delay(2.minutes)
while (isActive) {
delay(30.seconds)
val heapPct = heapUsagePercent()
val elapsed = System.currentTimeMillis() - lastCleanupTime
if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) {
runCleanup()
}
}
}
}
private suspend fun runCleanup() {
val ops =
listOf<Pair<String, suspend () -> Unit>>(
"cleanMemory" to { localCache.cleanMemory() },
"cleanObservers" to { cleanObservers() },
)
ops.forEach { (name, op) ->
try {
op()
} catch (e: Exception) {
println("Cleanup $name failed: ${e.message}")
}
}
lastCleanupTime = System.currentTimeMillis()
}
private fun cleanObservers() {
localCache.notes.forEach { _, note -> note.clearFlow() }
localCache.addressableNotes.forEach { _, note -> note.clearFlow() }
}
private fun heapUsagePercent(): Double {
val heap = memoryBean.heapMemoryUsage
return heap.used.toDouble() / heap.max.toDouble()
} }
} }
@@ -45,7 +45,6 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
@@ -97,8 +96,6 @@ fun DeckColumnContainer(
iAccount: DesktopIAccount, iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: DesktopDraftStore,
appScope: CoroutineScope, appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -139,8 +136,6 @@ fun DeckColumnContainer(
iAccount = iAccount, iAccount = iAccount,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope, appScope = appScope,
compactMode = true, compactMode = true,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
@@ -149,7 +144,6 @@ fun DeckColumnContainer(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
) )
if (currentOverlay != null) { if (currentOverlay != null) {
Surface( Surface(
@@ -163,14 +157,11 @@ fun DeckColumnContainer(
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() }, onBack = { navState.pop() },
) )
} }
@@ -189,8 +180,6 @@ internal fun RootContent(
iAccount: DesktopIAccount, iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
appScope: CoroutineScope, appScope: CoroutineScope,
compactMode: Boolean = false, compactMode: Boolean = false,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
@@ -199,7 +188,6 @@ internal fun RootContent(
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -249,11 +237,8 @@ internal fun RootContent(
relayManager = relayManager, relayManager = relayManager,
localCache = localCache, localCache = localCache,
account = account, account = account,
nwcConnection = nwcConnection,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle, onNavigateToArticle = onNavigateToArticle,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
) )
} }
@@ -296,7 +281,6 @@ internal fun RootContent(
onBack = {}, onBack = {},
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
) )
} }
@@ -352,16 +336,16 @@ internal fun RootContent(
localCache = localCache, localCache = localCache,
account = account, account = account,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = {}, onBack = {},
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
) )
} }
is DeckColumnType.Editor -> { DeckColumnType.Editor -> {
val draftStore = remember { DesktopDraftStore(scope) }
ArticleEditorScreen( ArticleEditorScreen(
draftSlug = columnType.draftSlug, draftSlug = null,
draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, draftStore = draftStore,
account = account, account = account,
relayManager = relayManager, relayManager = relayManager,
onBack = {}, onBack = {},
@@ -370,16 +354,10 @@ internal fun RootContent(
} }
DeckColumnType.Drafts -> { DeckColumnType.Drafts -> {
val draftStore = remember { DesktopDraftStore(scope) }
DraftsScreen( DraftsScreen(
draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, draftStore = draftStore,
onOpenEditor = { slug -> onNavigateToEditor(slug) }, onOpenEditor = {},
)
}
DeckColumnType.MyHighlights -> {
com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen(
highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) },
onNavigateToArticle = onNavigateToArticle,
) )
} }
@@ -404,14 +382,11 @@ internal fun OverlayContent(
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?, nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit, onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
when (screen) { when (screen) {
@@ -426,7 +401,6 @@ internal fun OverlayContent(
onBack = onBack, onBack = onBack,
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback, onZapFeedback = onZapFeedback,
) )
} }
@@ -454,24 +428,11 @@ internal fun OverlayContent(
localCache = localCache, localCache = localCache,
account = account, account = account,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = onBack, onBack = onBack,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
) )
} }
is DesktopScreen.Editor -> {
val overlayScope = androidx.compose.runtime.rememberCoroutineScope()
ArticleEditorScreen(
draftSlug = screen.draftSlug,
draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) },
account = account,
relayManager = relayManager,
onBack = onBack,
onPublished = onBack,
)
}
else -> { else -> {
androidx.compose.material3.Text( androidx.compose.material3.Text(
"Unsupported screen type", "Unsupported screen type",