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:
@@ -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
|
||||||
|
|||||||
Vendored
+8
@@ -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()
|
||||||
|
|||||||
+57
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-46
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user