From f8040e5962d182865bc0e80debbe0d1d0a89dc5a Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:49:55 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20record=20DVM=20heartbeats=20in=20a=20str?= =?UTF-8?q?ong=20registry=20=E2=80=94=20beat=20notes=20are=20WeakReference?= =?UTF-8?q?-held?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beat notes live in LocalCache.addressables (WeakReference values) with no strong holder on the Discover screen: every GC sweep cleared them all at once, the freshness gate failed for every DVM simultaneously, and the list collapsed and rebuilt one beat at a time (worst on refresh — an allocation spike). The detail screen survived only because its own composable held the note. DvmHeartbeatRegistry (Address -> latest createdAt, strong, one entry per DVM) is now the freshness source: recorded on every beat consumption, read by the gate and the liveness composables. Stale entries are inert; the map is bounded by distinct DVM addresses. --- .../amethyst/model/DvmHeartbeat.kt | 11 +++- .../amethyst/model/DvmHeartbeatRegistry.kt | 58 +++++++++++++++++++ .../amethyst/model/LocalCache.kt | 8 ++- .../loggedIn/dvms/DvmHeartbeatObservation.kt | 10 +++- .../amethyst/model/DvmHeartbeatTest.kt | 12 ++++ 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt index 7a4fc9e8b4..aafb366746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt @@ -30,11 +30,18 @@ import com.vitorpamplona.quartz.utils.TimeUtils /** The cache slot a DVM's heartbeat lives in: the announcement's own address, kind 11998. */ fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent? = getAddressableNoteIfExists(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))?.event as? DvmHeartbeatEvent -/** A DVM counts as alive only if its latest heartbeat is at most 420s old. */ +/** + * A DVM counts as alive only if its latest heartbeat is at most 900s old. The registry (not the + * WeakReference-held beat note) is the freshness source: beat notes have no strong holder on the + * Discover screen, and a GC sweep cleared them all at once, collapsing the list. + */ fun LocalCache.hasFreshDvmHeartbeat( appDef: AppDefinitionEvent, now: Long = TimeUtils.now(), -): Boolean = dvmHeartbeatOf(appDef)?.isFreshAt(now) == true +): Boolean = + DvmHeartbeatRegistry + .latestAt(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())) + ?.let { it >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS } == true /** * Every cached content-discovery announcement, WITHOUT the freshness gate — this is the source the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt new file mode 100644 index 0000000000..5f7ec90245 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.ConcurrentHashMap + +/** + * Strong, process-wide record of the latest heartbeat per DVM announcement address + * (`Address(11998, dvmPubKey, dTag) -> createdAt`). + * + * Beat Notes themselves live in `LocalCache.addressables`, a WeakReference store with no strong + * holder on the Discover screen — every GC sweep cleared them all at once and the freshness gate + * collapsed for every DVM simultaneously (the list emptied and rebuilt one beat at a time). This + * registry is the freshness source the gate and the liveness composables read: strong references, + * fed by every beat-arrival path (global REQ, outbox batches, per-surface fetches — all beat + * consumption routes through [record]). + * + * One entry per DVM address ever seen; timestamps only, so it stays tiny. `0` means "no beat". + */ +object DvmHeartbeatRegistry { + private val latestBeatCreatedAt = ConcurrentHashMap
>() + + private fun flowFor(address: Address): MutableStateFlow