fix: widen the DVM heartbeat drop window to 900s (hysteresis)

Beats arrive every 300s, so the old 420s window tolerated barely one
delivery hiccup — live DVMs dropped in oscillations and flickered back on
the next beat. The gate now keeps a DVM visible through several missed
deliveries (relay reconnects, REQ churn) before dropping it; a dead DVM
still disappears after 15 minutes. Single constant, all surfaces consistent.
This commit is contained in:
Dr. Tobias Baur
2026-09-10 15:28:25 +02:00
parent cd67c56909
commit 5a4002e256
5 changed files with 26 additions and 22 deletions
@@ -21,7 +21,7 @@ Kind 11998 sits in the replaceable range (10000–19999), so relays keep only th
per author. There is no NIP for this yet — the shape above comes from the operator-side
builder and is treated as the wire contract.
The feature: **a DVM counts as alive only if its latest heartbeat is at most 420 seconds
The feature: **a DVM counts as alive only if its latest heartbeat is at most 900 seconds
old** (one missed 300s beat plus slack). Dead DVMs disappear from the Discover list; pinned
feeds and the detail surface show an offline state instead.
@@ -36,7 +36,7 @@ feeds and the detail surface show an offline state instead.
(`FavoriteAlgoFeedsListScreen`) also gets the badge.
3. **Pinned chips stay when offline** — the user pinned them deliberately; they gray out
with an offline badge rather than vanishing, and tapping still opens the feed.
4. **Threshold: 420 seconds**, a named constant. Exactly 420s old counts as fresh.
4. **Threshold: 900 seconds** (raised from the original 420 after field testing: beats arrive every 300s, and a 420s window tolerated barely one delivery hiccup, dropping live DVMs in oscillations). Exactly 900s old counts as fresh.
5. **Strict from cold start.** No grace period: the Discover list starts empty and fills
within ~1–2s as heartbeat REQs return (same behavior as the existing 31990 load).
@@ -50,7 +50,7 @@ New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`:
`Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's
`Address(31990, dvmPubkey, dTag)`.
- Accessors: `status()`, and `expiration()` via the existing NIP-40 extension.
- `MAX_AGE_SECONDS = 420` and `isFreshAt(now)` live in quartz too (commons imports them).
- `MAX_AGE_SECONDS = 900` and `isFreshAt(now)` live in quartz too (commons imports them).
- Registered in `EventFactory` (kind → constructor) and allowlisted in
`EventFactoryKindRangeTest.knownDTagReaders`: the `d` tag keys the client-side address
while relay storage stays plain-replaceable per the kind range.
@@ -67,14 +67,13 @@ invisible. The cache pruner removes old entries on its own schedule.
## 4. Freshness core (amethyst)
Small helper file in `amethyst/.../dvms/`:
Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz):
- `const val DVM_HEARTBEAT_MAX_AGE_SECONDS = 420`
- `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address
lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())`
- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 420`
- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean` — `createdAt >= now - 900`
- `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State<Boolean>` —
as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 420s
as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 900s
old; an unresolved/absent beat counts as offline (`false`) on every surface. The returned
`State` identity is stable for the lifetime of the call site (one unconditional
`rememberUpdatedState`), so callers may capture it across recompositions. Composable-scoped
@@ -86,7 +85,7 @@ Small helper file in `amethyst/.../dvms/`:
**Discover screen — all DVM heartbeats.** In
`commons/.../relayClient/discover/nip90DVMs/SubAssemblyHelper.kt`, `makeContentDVMsFilter`
unconditionally appends one filter for every top-filter variant:
`kinds = [11998], since = TimeUtils.now() - 420` — no authors, no tags, scoped to the same
`kinds = [11998], since = TimeUtils.now() - 900` — no authors, no tags, scoped to the same
relay set as the 31990 REQs. It deliberately ignores the 31990 `since`-cursor (heartbeats
are a rolling window, not a cursor stream — the cursor would miss re-opened tabs after the
beats expired). It rides the existing assembler lifecycle: subscribes on entering Discover,
@@ -94,7 +93,7 @@ closes on leaving.
**Per-surface — pinned chips, home banner, detail screen.** `rememberDvmHeartbeat` opens a
tiny composable-scoped subscription: `kinds = [11998], authors = [dvm pubkey], limit = 1,
since = now - 420`. The home top-bar chips live for the whole session, so they double as
since = now - 900`. The home top-bar chips live for the whole session, so they double as
the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned DVMs ×
1 event / 5 min).
@@ -104,7 +103,7 @@ relays don't gossip, so alive DVMs whose beats never overlap the user's relay se
invisible (their detail screens proved the beats existed on the outbox). `DiscoveryDvmHeartbeatSubAssembler`
joins the discovery assembler group and, while Discover is composed, batches the cached
content-discovery announcements' authors per **DVM outbox relay** (`kinds = [11998],
authors = [those pubkeys], since = now - 420`, coverage-ranked and capped at 12 relays;
authors = [those pubkeys], since = now - 900`, coverage-ranked and capped at 12 relays;
authors with unknown outboxes/hints rely on the global REQ as fallback). It re-issues when
the cached announcement set or the NIP-65 relay lists move.
@@ -151,7 +150,7 @@ author (the same mix the event finder's `potentialRelaysToFindAddress` uses).
- Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden
(strict; the wire contract always sends `d`).
- Device/DVM clock skew > 7 min → wrongly hidden (inherent to timestamp-based liveness).
- Device/DVM clock skew > 15 min → wrongly hidden (inherent to timestamp-based liveness).
- DVM beats that never reach the relays we query → shows offline (that is the feature).
- One keypair running multiple DVMs → relays keep only the latest beat per (kind, author);
per-d-tag cache slots help only across relays. Most DVMs use one key each.
@@ -165,5 +164,5 @@ author (the same mix the event finder's `potentialRelaysToFindAddress` uses).
`expiration()`, address assembly.
- **amethyst**: `DiscoverNIP89FeedFilter.acceptApp` matrix — no beat → reject; fresh beat →
accept; 421s-old beat → reject. The `updateFeedsWith` heartbeat branch triggers a full
rebuild. `isFreshAt` boundary (420s fresh, 421s stale).
rebuild. `isFreshAt` boundary (900s fresh, 901s stale).
- Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`.
@@ -83,16 +83,16 @@ class DvmHeartbeatTest {
fun aFreshHeartbeatPassesTheGateAndAStaleOneDoesNot() {
val now = 1_760_000_000L
// Separate dTags: consumeBaseReplaceable only accepts NEWER beats per address, so a
// 421s-old beat could never supersede the 420s one within a single address slot.
// 901s-old beat could never supersede the 900s one within a single address slot.
val freshApp = appDef("dvm-two-fresh")
val staleApp = appDef("dvm-two-stale")
assertNull("no beat yet", LocalCache.dvmHeartbeatOf(freshApp))
LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 420, id = "f4".repeat(32)), null, true)
LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 421, id = "f5".repeat(32)), null, true)
LocalCache.justConsume(beat("dvm-two-fresh", createdAt = now - 900, id = "f4".repeat(32)), null, true)
LocalCache.justConsume(beat("dvm-two-stale", createdAt = now - 901, id = "f5".repeat(32)), null, true)
assertTrue("exactly 420s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now))
assertFalse("421s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now))
assertTrue("exactly 900s old counts as fresh", LocalCache.hasFreshDvmHeartbeat(freshApp, now))
assertFalse("901s old is stale", LocalCache.hasFreshDvmHeartbeat(staleApp, now))
}
@Test
@@ -82,7 +82,7 @@ class DvmHeartbeatOutboxFiltersTest {
filters.forEach {
assertEquals(listOf(DvmHeartbeatEvent.KIND), it.filter.kinds)
assertEquals(1_760_000_000L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS")
assertEquals(1_759_999_520L, it.filter.since, "rolling window: now - MAX_AGE_SECONDS")
}
}
@@ -61,8 +61,13 @@ class DvmHeartbeatEvent(
const val STATUS_TAG = "status"
const val CONTENT = "Alive and kicking"
/** A beat older than this no longer proves liveness (one missed 300s beat + slack). */
const val MAX_AGE_SECONDS = 420
/**
* A beat older than this no longer proves liveness. Beats arrive every 300s, so this
* window deliberately tolerates several missed deliveries (relay reconnects, REQ churn)
* before a DVM is dropped — hysteresis against transient delivery gaps, at the cost of a
* dead DVM lingering this long before disappearing.
*/
const val MAX_AGE_SECONDS = 900
fun build(
dTag: String,
@@ -90,8 +90,8 @@ class DvmHeartbeatEventTest {
@Test
fun freshnessBoundary() {
val event = heartbeat()
assertTrue(event.isFreshAt(beatTime + 420))
assertFalse(event.isFreshAt(beatTime + 421))
assertTrue(event.isFreshAt(beatTime + 900))
assertFalse(event.isFreshAt(beatTime + 901))
}
@Test