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()
data object Drafts : DesktopScreen()
data object Settings : DesktopScreen()
}
@@ -457,7 +456,7 @@ fun App(
RelayUrlNormalizer.normalizeOrNull(it)
}.toSet(),
localCache = localCache,
)
).also { it.startCleanupLoop() }
}
// Clear cache and subscriptions on logout
@@ -515,6 +515,14 @@ class DesktopLocalCache : ICacheProvider {
followingCounts[pubkey] = count
}
// ----- Memory Cleanup -----
fun cleanMemory() {
notes.cleanUp()
addressableNotes.cleanUp()
users.cleanUp()
}
// ----- Stats -----
fun userCount(): Int = users.size()
@@ -37,11 +37,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.lang.management.ManagementFactory
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Desktop-specific relay subscriptions coordinator.
@@ -356,5 +361,57 @@ class DesktopRelaySubscriptionsCoordinator(
unsubscribeFromDms()
feedMetadata.clear()
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.network.DesktopRelayConnectionManager
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.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
@@ -97,8 +96,6 @@ fun DeckColumnContainer(
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore,
draftStore: DesktopDraftStore,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
@@ -139,8 +136,6 @@ fun DeckColumnContainer(
iAccount = iAccount,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
appScope = appScope,
compactMode = true,
onShowComposeDialog = onShowComposeDialog,
@@ -149,7 +144,6 @@ fun DeckColumnContainer(
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
)
if (currentOverlay != null) {
Surface(
@@ -163,14 +157,11 @@ fun DeckColumnContainer(
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
draftStore = draftStore,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onBack = { navState.pop() },
)
}
@@ -189,8 +180,6 @@ internal fun RootContent(
iAccount: DesktopIAccount,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
appScope: CoroutineScope,
compactMode: Boolean = false,
onShowComposeDialog: () -> Unit,
@@ -199,7 +188,6 @@ internal fun RootContent(
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
) {
val scope = rememberCoroutineScope()
@@ -249,11 +237,8 @@ internal fun RootContent(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
@@ -296,7 +281,6 @@ internal fun RootContent(
onBack = {},
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
}
@@ -352,16 +336,16 @@ internal fun RootContent(
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = {},
onNavigateToProfile = onNavigateToProfile,
)
}
is DeckColumnType.Editor -> {
DeckColumnType.Editor -> {
val draftStore = remember { DesktopDraftStore(scope) }
ArticleEditorScreen(
draftSlug = columnType.draftSlug,
draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
draftSlug = null,
draftStore = draftStore,
account = account,
relayManager = relayManager,
onBack = {},
@@ -370,16 +354,10 @@ internal fun RootContent(
}
DeckColumnType.Drafts -> {
val draftStore = remember { DesktopDraftStore(scope) }
DraftsScreen(
draftStore = draftStore ?: remember { DesktopDraftStore(scope) },
onOpenEditor = { slug -> onNavigateToEditor(slug) },
)
}
DeckColumnType.MyHighlights -> {
com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen(
highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) },
onNavigateToArticle = onNavigateToArticle,
draftStore = draftStore,
onOpenEditor = {},
)
}
@@ -404,14 +382,11 @@ internal fun OverlayContent(
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
highlightStore: DesktopHighlightStore? = null,
draftStore: DesktopDraftStore? = null,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToArticle: (String) -> Unit = {},
onBack: () -> Unit,
) {
when (screen) {
@@ -426,7 +401,6 @@ internal fun OverlayContent(
onBack = onBack,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
}
@@ -454,24 +428,11 @@ internal fun OverlayContent(
localCache = localCache,
account = account,
subscriptionsCoordinator = subscriptionsCoordinator,
highlightStore = highlightStore,
onBack = onBack,
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 -> {
androidx.compose.material3.Text(
"Unsupported screen type",