feat: scale LocalCache pruning aggressiveness to OS memory-pressure level

MemoryTrimmingService.doTrim() previously ran the full pruning suite
regardless of how severe the OS signal was. Now it tiers the work by
ComponentCallbacks2 level so low-pressure signals don't pay the cost of
aggressive observer teardown:

  Tier 1 (always): cleanMemory + pruneExpiredEvents + prunePastVersionsOfReplaceables
  Tier 2 (>= RUNNING_LOW): + pruneOldMessages + pruneRepliesAndReactions
  Tier 3 (>= RUNNING_CRITICAL): + cleanObservers + pruneHiddenEvents/Messages

The `level` is now threaded from Amethyst.onTrimMemory → AppModules.trim(level)
→ MemoryTrimmingService.run(…, level) → doTrim(…, level). The existing
scheduled-trim call site (AppModules.trim) defaults to RUNNING_CRITICAL so
its behaviour is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-24 20:11:27 +00:00
parent 21e61e48ae
commit f038e54fa2
3 changed files with 38 additions and 13 deletions
@@ -852,12 +852,12 @@ class AppModules(
accountsCache.clear()
}
fun trim() {
fun trim(level: Int) {
applicationIOScope.launch {
// Backgrounding is a natural moment to flush the DNS cache.
dnsStore.save()
val loggedIn = accountsCache.accounts.value.values
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level)
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.eventCache
import android.content.ComponentCallbacks2
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -32,33 +33,57 @@ class MemoryTrimmingService(
) {
var isTrimmingMemoryMutex = AtomicBoolean(false)
/**
* Tiered pruning scaled to the OS memory-pressure level.
*
* Tier 1 mild pressure (UI hidden, running-moderate):
* Sweep stale WeakRefs, drop expired and superseded-replaceable events.
* Safe to run frequently; no UI-visible side effects.
*
* Tier 2 low memory (running-low, background):
* Tier 1 + old chat messages + unobserved thread replies / reactions.
* May cause feeds to re-fetch content that was scrolled past.
*
* Tier 3 critical / imminent kill (running-critical, moderate, complete):
* Tier 2 + sever all observer links + drop every event from muted/blocked users.
* Aggressive; triggers recomposition wherever StateFlows were cleared.
*/
private fun doTrim(
account: Collection<Account>,
otherAccounts: List<AccountInfo>,
level: Int,
) {
// Tier 1: always run — cheap housekeeping
cache.cleanMemory()
cache.cleanObservers()
cache.pruneExpiredEvents()
cache.prunePastVersionsOfReplaceables()
account.forEach {
cache.pruneHiddenEvents(it)
cache.pruneHiddenMessages(it)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
// Tier 2: medium pressure — drop messages and unobserved reactions
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneRepliesAndReactions(accounts)
}
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneRepliesAndReactions(accounts)
cache.prunePastVersionsOfReplaceables()
cache.pruneExpiredEvents()
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
// Tier 3: critical pressure — sever observer links and drop muted content
cache.cleanObservers()
account.forEach {
cache.pruneHiddenEvents(it)
cache.pruneHiddenMessages(it)
}
}
}
suspend fun run(
account: Collection<Account>,
otherAccounts: List<AccountInfo>,
level: Int = ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
) {
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
Log.d("ServiceManager", "Trimming Memory")
Log.d("ServiceManager", "Trimming Memory (level=$level)")
try {
doTrim(account, otherAccounts)
doTrim(account, otherAccounts, level)
} finally {
isTrimmingMemoryMutex.getAndSet(false)
}