perf(graperank): mass pre-connect, cached DNS, and an amy relay census probe

Most of a from-scratch crawl's wall clock was connection setup, re-paid
serially: the relay pool tears down a relay's socket ~300ms after its last
drain unsubscribes, OkHttp allowed only 256 concurrent WS handshakes, and
every dial re-ran an uncached blocking getaddrinfo (10-30s per dead domain,
once per per-user path URL of the same host). Open the connections once, in
parallel, and keep them:

- GrapeRankCrawler: the warm-pool trick (never-matching REQ that only holds
  the socket) now covers the whole candidate universe instead of the top 20 -
  seeded at crawl start from the reachability cache's live set (one parallel
  connection storm, Config.knownLiveRelays) and refreshed each round with
  newly learned outbox relays, capped by Config.preconnectCap (FD-budget
  aware, --preconnect-cap / --no-preconnect).
- CachingDns (quartz jvmAndroid): 10-min positive + negative DNS cache with
  in-flight per-host dedup; dead domains fail in microseconds instead of
  re-burning resolver timeouts, path URLs of one host resolve once.
- cli Context: dispatcher and pre-connect caps derived from the process's
  open-files limit (UnixOperatingSystemMXBean), warning when ulimit is low.
- amy graperank probe: relay census - mass-connects every relay the store
  knows (kind:10002 universe deduped per authority + cached verdicts) in
  waves, records live/dead with real measured rtt-open into the NIP-66
  reachability cache (RelayProber + RelayReachabilityStore.recordProbed),
  so the next crawl skips dead relays and waits once for the slow-but-alive.

Single-server limits (FDs, ephemeral ports, DNS, threads, conntrack) and the
design are documented in quartz/plans/2026-07-10-graperank-connect-storm.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
This commit is contained in:
Claude
2026-07-10 01:13:23 +00:00
parent 268a9ff33e
commit 2bad6779ce
10 changed files with 946 additions and 17 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.cli
import com.sun.management.UnixOperatingSystemMXBean
import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore
import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.CachingDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -83,6 +85,7 @@ import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import java.lang.management.ManagementFactory
import java.util.concurrent.TimeUnit
/**
@@ -144,14 +147,24 @@ class Context(
// already-open sockets, bounded by AdaptiveRelayLimiter), so it can't
// trip a relay's REQ rate-limit — it only speeds connection setup. The
// executor thread pool is unbounded on demand, so raising maxRequests
// just lets more of those short-lived handshakes proceed at once. 7s
// just lets more of those short-lived handshakes proceed at once
// (1 platform thread per in-flight handshake — ~1k is fine on a JVM,
// 10k is not). Sized against the process FD budget: every pending
// handshake and every open socket is one file descriptor. 7s
// (not 5s): a 5s cap struck too many merely-busy relays as connect
// failures — the crawl treats a connect *timeout* as retryable anyway,
// but the extra headroom lets slow-but-alive relays finish the handshake.
.connectTimeout(7, TimeUnit.SECONDS)
// DNS dominates a mass connection ramp without help: Dns.SYSTEM blocks a
// dispatcher thread per lookup, the JVM's own cache lasts ~30s (useless
// over a 30-minute crawl), a dead domain burns 10-30s of resolver
// timeouts on EVERY re-dial, and the outbox model mints hundreds of
// per-user URLs on one host — each a fresh lookup. The cache (10-min
// positive + negative TTL, in-flight dedup per host) collapses all of it.
.dns(CachingDns())
.dispatcher(
Dispatcher().apply {
maxRequests = 256
maxRequests = maxParallelHandshakes
maxRequestsPerHost = 16
},
).build()
@@ -1033,6 +1046,39 @@ class Context(
*/
private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60
/** FDs reserved for everything that isn't a relay socket (store, jars, pipes, DNS). */
private const val FD_RESERVE = 256L
/**
* The process's max-open-files limit (`ulimit -n`), the hard ceiling on
* concurrent sockets: every open WebSocket AND every in-flight handshake is
* one file descriptor, and the JVM cannot raise its own rlimit. Falls back
* to the conservative 1024 (the common soft default) when the platform bean
* doesn't expose it.
*/
val maxFileDescriptors: Long =
(ManagementFactory.getOperatingSystemMXBean() as? UnixOperatingSystemMXBean)
?.maxFileDescriptorCount ?: 1024L
/**
* Concurrent WS-upgrade handshakes (OkHttp Dispatcher.maxRequests): a quarter
* of the FD budget, so pending dials can never crowd out the sockets already
* held open (warm pool, drains, parked subs). Also bounds the transient
* platform threads OkHttp spawns — one per in-flight handshake.
*/
val maxParallelHandshakes: Int =
((maxFileDescriptors - FD_RESERVE) / 4).coerceIn(64, 1024).toInt()
/**
* Default cap for the crawl's mass pre-connect (warm pool): half the FD
* budget goes to held-open relay sockets, leaving the other half for
* in-flight handshakes, drain/parked subscriptions and headroom. At the
* common 1024-FD soft limit this is ~384; `ulimit -n 16384` unlocks the
* full 4000. Overridable per-run with --preconnect-cap.
*/
val defaultPreconnectCap: Int =
((maxFileDescriptors - FD_RESERVE) / 2).coerceIn(100, 4000).toInt()
/**
* Build a Context but require an account with a usable identity —
* signing verbs can't run without one. Throws [IllegalArgumentException]
@@ -609,6 +609,14 @@ private fun printUsage() {
| new/changed ranks >= --min-rank (default 2), skips
| unchanged, and retracts (kind:5) any card whose
| target left the graph or fell below the cutoff.
| graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/
| [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring.
| [--no-preconnect] Pre-connects every known-live relay in one parallel
| storm (seeded from the reachability cache).
| graperank probe [--timeout SECS] relay census: mass-connect every relay the store
| [--concurrency N] knows and record live/dead + measured rtt-open into
| the reachability cache (NIP-66 kind:30166), so the
| next crawl skips dead relays and waits once.
| graperank update [--down] [--up] refresh every locally-known author's WoT record kinds
| [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all
| [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes
@@ -92,6 +93,9 @@ import kotlin.math.roundToInt
* - `amy graperank score [OBSERVER]` local only: build the graph from the store
* and score (same as bare `--offline`). Instant and param-tunable; repeat with
* different `--rigor`/`--attenuation`/cutoffs without re-crawling.
* - `amy graperank probe` the relay census: mass-connect every relay the store
* knows and record live/dead + measured RTT into the reachability cache, so the
* next crawl skips the dead and pre-connects the living in one parallel storm.
* - bare `amy graperank [OBSERVER]` the convenience combo: crawl then score.
*
* Sub-verbs complete the NIP-85 provider experience the discovery layer that
@@ -193,6 +197,7 @@ object GrapeRankCommand {
// `sync` is the pre-rename name kept as a back-compat alias; `crawl` is
// canonical (disambiguates from negentropy `amy sync` / `graperank update`).
"crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray())
"probe" -> probe(dataDir, tail.drop(1).toTypedArray())
"update" -> update(dataDir, tail.drop(1).toTypedArray())
"score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true)
else -> run(dataDir, tail)
@@ -441,11 +446,25 @@ object GrapeRankCommand {
// Aggregator kind:3 recovery for stragglers is on by default; --no-aggregators
// disables it for A/B comparison.
val aggregators = if (args.bool("no-aggregators")) emptySet() else CONTENT_AGGREGATOR_RELAYS
// Seed the crawl with relays a prior run/monitor proved dead within the cache's
// TTL, so the WS path never re-pays their connect timeouts (--no-reachability-cache
// to skip). The crawl's own final live/dead set is flushed back by the caller.
val knownDead =
if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead
// Seed the crawl from the reachability cache (--no-reachability-cache to skip):
// proven-dead relays within the TTL are never dialed (their connect timeouts
// aren't re-paid), and the proven-live universe is pre-connected in one
// parallel storm at crawl start so the connect wait is paid once, up front.
// The crawl's own final live/dead set is flushed back by the caller.
val reachability =
if (args.bool("no-reachability-cache")) null else ctx.reachability.snapshot()
val knownDead = reachability?.dead ?: emptySet()
// Mass pre-connect sizing: every warm socket is one FD, so the default is
// derived from the process's ulimit (--preconnect-cap to override,
// --no-preconnect to fall back to the top-20 warm pool).
val preconnectCap =
if (args.bool("no-preconnect")) 0 else args.intFlag("preconnect-cap", Context.defaultPreconnectCap)
if (preconnectCap > 0 && Context.maxFileDescriptors < 4096) {
System.err.println(
"[graperank] open-files limit is ${Context.maxFileDescriptors} → pre-connect capped at " +
"$preconnectCap sockets; `ulimit -n 16384` before running unlocks a faster crawl",
)
}
return GrapeRankCrawler(
client = ctx.client,
store = ctx.store,
@@ -454,6 +473,8 @@ object GrapeRankCommand {
GrapeRankCrawler.Config(
relayListDiscoveryRelays = discoveryRelays,
knownDeadRelays = knownDead,
knownLiveRelays = if (preconnectCap > 0) reachability?.live.orEmpty() else emptySet(),
preconnectCap = preconnectCap,
contentFallbackRelays = contentFallback,
contentAggregatorRelays = aggregators,
maxRounds = args.intFlag("max-rounds", Int.MAX_VALUE),
@@ -548,6 +569,86 @@ object GrapeRankCommand {
return 0
}
/**
* `amy graperank probe [--timeout SECS] [--concurrency N]`
* the relay census. Mass-connects the ENTIRE relay universe the local store knows
* (every relay advertised in any stored kind:10002, deduped per host, plus
* everything already in the reachability cache) in parallel waves with a no-op
* REQ, so the "is this relay alive, and how slow?" wait is paid once, up front,
* concurrently then records per-relay verdicts with real measured `rtt-open`
* into the NIP-66 reachability cache (kind:30166).
*
* The next `graperank crawl` reads that cache to (a) skip the dead set without
* dialing it and (b) pre-connect the live set in one storm separating "working
* but slow" (kept; the crawler's patient park path waits for them) from "not
* working" (skipped entirely). Typical flow the first time:
* `graperank crawl --max-hops 2` (cheap, saves the relay lists) `graperank
* probe` full `graperank crawl`.
*/
private suspend fun probe(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val timeoutMs = args.longFlag("timeout", 15L) * 1000
val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap)
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val cached = ctx.reachability.snapshot()
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead
if (universe.isEmpty()) {
Output.emit(
linkedMapOf<String, Any?>(
"probed" to 0,
"note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists",
),
)
return 0
}
System.err.println(
"[relay-probe] probing ${universe.size} relays in waves of $waveSize " +
"(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})",
)
val result =
RelayProber(ctx.client) { System.err.println(it) }
.probe(universe, timeoutMs, waveSize)
ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays())
val rtts =
result.reachable
.map { it.rttOpenMs }
.filter { it >= 0 }
.sorted()
fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100]
val slowest =
result.reachable
.filter { it.rttOpenMs >= 0 }
.sortedByDescending { it.rttOpenMs }
.take(10)
.map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) }
val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true }
Output.emit(
linkedMapOf<String, Any?>(
"probed" to result.verdicts.size,
"reachable" to result.reachable.size,
"dead" to result.dead.size,
"closed_by_policy" to authWalled,
"elapsed_ms" to result.elapsedMs,
"rtt_open_p50_ms" to pct(50),
"rtt_open_p90_ms" to pct(90),
"rtt_open_p99_ms" to pct(99),
"slowest" to slowest,
),
)
}
return 0
}
/**
* `amy graperank update [flags]` refresh every locally-known author's WoT
* record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the