From 612f2aa31efdf64acd8651a0cd0279a3e0ac4ebe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 14:09:30 +0000 Subject: [PATCH] fix(nests): prune stale presence entries from LiveActivitiesChannel Presence entries are valid for ~10 min (PRESENCE_FRESHNESS_WINDOW_SECONDS in NestsFeedFilter). Without explicit cleanup, presenceNotes would grow unbounded: every author who ever heartbeats in a room leaves an entry there forever. The freshness check kept the filter result correct, but memory only ever grew. Add LiveActivitiesChannel.pruneStalePresence(cutoff) and call it from LocalCache.pruneOldMessagesChannel alongside the existing notes prune. Cutoff is 2x the freshness window (20 min) so a presence still inside any feed's window can never be reaped. --- .../amethyst/model/LocalCache.kt | 13 ++++++++++++ .../LiveActivitiesChannel.kt | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ae6d013ef..9dee631e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2241,6 +2241,11 @@ object LocalCache : ILocalCache, ICacheProvider { } } + // 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by + // `NestsFeedFilter` so a presence still inside any feed's window + // can never be pruned. + private val PRESENCE_PRUNE_AGE_SECONDS = 20L * 60L + fun pruneOldMessagesChannel(channel: Channel) { val toBeRemoved = channel.pruneOldMessages() @@ -2254,6 +2259,14 @@ object LocalCache : ILocalCache, ICacheProvider { removeFromCache(childrenToBeRemoved) + // Audio-room presence is keyed separately from `notes` and + // never gets reaped by the top-N rule. Drop entries older + // than 2× the 10-min freshness window so the index doesn't + // grow unbounded with every author who ever heartbeat here. + if (channel is LiveActivitiesChannel) { + channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS) + } + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { println( "PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt index 75b202dae..1638b54d6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -80,6 +80,27 @@ class LiveActivitiesChannel( flowSet?.notes?.invalidateData() } + /** + * Drop presence entries older than [cutoff]. Without this, every + * author who ever heartbeats in this room would leave an entry + * here forever, even though the freshness check in + * `NestsFeedFilter` already treats anything past + * `PRESENCE_FRESHNESS_WINDOW_SECONDS` (10 min) as dead. Run on the + * same schedule as [pruneOldMessages] with a generous cutoff (2× + * the freshness window) so a presence still inside any feed's + * window can never be pruned. + */ + fun pruneStalePresence(cutoff: Long): Int { + val toRemove = mutableListOf() + presenceNotes.forEach { author, note -> + val createdAt = note.event?.createdAt + if (createdAt == null || createdAt < cutoff) toRemove.add(author) + } + toRemove.forEach { presenceNotes.remove(it) } + if (toRemove.isNotEmpty()) flowSet?.notes?.invalidateData() + return toRemove.size + } + fun address() = address override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays()