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
@@ -0,0 +1,89 @@
# GrapeRank crawl: connect once, wait once (connection storm)
## Problem
A from-scratch `amy graperank crawl` spends most of its wall clock **waiting to
connect** to relays, not downloading. Three compounding causes, all on our side:
1. **Sockets are torn down between drains.** `NostrClient` reconciles the relay
pool against the union of *active* subscriptions (`RelayPool.updatePool`,
sampled every 300ms). A Phase-B drain unit unsubscribes when it finishes, so
an outbox relay with no other in-flight drain is **disconnected ~300ms
later** — and the next round re-pays DNS + TCP + TLS + WS-upgrade for the
same relay. Only the top-20 "warm pool" relays kept a do-nothing
subscription open. A crawl touches ~1015k distinct relay URLs; most get
dialed many times.
2. **Handshake concurrency was capped at 256** (OkHttp `Dispatcher.maxRequests`;
the OkHttp default is 64). Every WS upgrade is an async call holding a
dispatcher slot for its whole DNS + TCP + TLS + upgrade; a dead relay holds
a slot for the full 7s `connectTimeout`. At 256 slots, dialing 5k relays
where half are dead is ~20 serialized waves — minutes of pure waiting that
could be one parallel wait.
3. **DNS is blocking, uncached and duplicated.** OkHttp's `Dns.SYSTEM` calls
`InetAddress.getAllByName` (glibc `getaddrinfo`) on the dispatcher thread.
The JVM's own cache is ~30s positive / ~10s negative — useless across a
30-minute crawl that re-dials the same hosts every round. Worse, the outbox
model mints hundreds of per-user *URLs* on one *host*
(`filter.nostr.wine/npubA`, `/npubB`, …) and each URL is a fresh lookup. A
dead domain is the worst case: resolver timeouts (5s × 2 attempts per
nameserver) can hold a thread 1030s, and the crawl re-resolves it from
every straggler's outbox, every round.
## Single-server limits (what bounds "1000s of connections at once")
Measured/derived for a JVM CLI on one Linux box:
| Resource | Limit | Consequence |
|---|---|---|
| File descriptors | `ulimit -n` soft (often 10244096; systemd default 1024 soft / 512k+ hard) | 1 FD per socket. The JVM cannot raise its own rlimit — detect via `UnixOperatingSystemMXBean` and size the storm to fit; advise `ulimit -n 16384` for big crawls. |
| Ephemeral ports | ~28k (`ip_local_port_range`) **per destination tuple** | Non-issue: connections go to thousands of *distinct* relays, each gets its own tuple space. |
| Concurrent TCP connects | Kernel: effectively unbounded (a pending SYN is just a socket in `SYN_SENT`) | Dead-IP dials cost nothing in CPU but hold their FD + dispatcher slot for the connect timeout. Parallelism is the fix; refused/reset fails in 1 RTT. |
| DNS | glibc `getaddrinfo` is blocking, ~1 thread per in-flight lookup; dead domains 1030s | Cache positives AND negatives in-process; dedupe concurrent lookups for the same host (path-URL explosion). |
| Threads | 1 platform thread per in-flight handshake (OkHttp) | 12k concurrent handshakes ≈ 12k transient threads — fine on a 64-bit JVM (~stack is virtual), but don't go to 10k. |
| TLS | CPU per handshake (~1ms) | Negligible next to network RTTs; OkHttp shares an SSL session cache per client. |
| Middleboxes | Home-router NAT/conntrack tables (~416k entries) | Operational caveat for residential operators; server/VPS operators unaffected (`nf_conntrack_max` default ~256k). |
Conclusion: with FDs raised and DNS cached, a single server comfortably opens
**a few thousand concurrent connections**; the binding constraints are the FD
soft limit and the dispatcher cap, both of which we now size/raise explicitly.
## Design
Three changes, all keeping completeness identical (same relays asked the same
filters — only *when* sockets open changes):
1. **Crawl-wide warm pool = mass pre-connect.** The existing warm-pool trick
(a never-matching `ids` filter that EOSEs instantly and just holds the
socket) is extended from the top-20 relays to **every candidate relay we
know** — seeded at crawl start from the NIP-66 reachability cache's live
set, and refreshed every round with the relays learned from kind:10002s —
capped by `preconnectCap` (FD-budget-aware, busiest relays first, dead
relays excluded). Connections open in one parallel storm at start (and in
the background as new relays are learned), stay up for the whole crawl, and
every drain hits an already-open socket. This also implements the "crawl
once with --max-hops to save the relay list" flow: the first (even shallow)
crawl populates the store with 10002s and the reachability cache with
live/dead verdicts; the next crawl pre-connects that whole universe and
waits once.
2. **Transport ceilings raised (CLI).** `Dispatcher.maxRequests` 256 → 1024
(per-host stays 16 — politeness to path-multiplexed hosts is a *server*
property), and a `CachingDns` (10-min positive + negative TTL, in-flight
dedup per host) replaces `Dns.SYSTEM`. FD budget is detected at startup and
sizes both the dispatcher and the default `preconnectCap`.
3. **`amy graperank probe` — the relay census.** Reads the full known relay
universe (every relay in stored kind:10002s + everything in the
reachability cache), mass-connects it in waves with a never-matching REQ,
and records per-relay verdicts with **real** `rtt-open` into the NIP-66
store: connected → live (with measured RTT, however slow), cannot-connect /
never-completed → dead (TTL'd, re-probed after expiry). Separates
"working but slow" (kept, given the crawler's patient park path) from
"not working" (skipped entirely) without burning crawl time on the
distinction.
## Non-goals
- No change to REQ concurrency per relay (`AdaptiveRelayLimiter`) or the
drain worker count — the 64-worker A/B showed the *REQ* fan-out re-floods
busy hubs; this work only parallelizes and amortizes *connection setup*.
- No async-DNS library dependency; cached blocking lookups on OkHttp's
existing threads are sufficient once deduped and negative-cached.
@@ -208,6 +208,27 @@ class GrapeRankCrawler(
* permanently ignores an author's advertised home. See RelayReachabilityStore.
*/
val knownDeadRelays: Set<NormalizedRelayUrl> = emptySet(),
/**
* Relays a prior run (or another monitor) proved REACHABLE within the
* reachability cache's TTL. Seeded into the crawl-wide warm pool at start, so
* their DNS + TCP + TLS + WS-upgrade cost is paid ONCE, in one parallel
* connection storm, before the first round — instead of serially inside each
* drain that first routes to them. See [preconnectCap].
*/
val knownLiveRelays: Set<NormalizedRelayUrl> = emptySet(),
/**
* Cap on the crawl-wide warm pool — the mass pre-connect. The client tears a
* relay's socket down ~300ms after its last subscription closes, so without a
* warm hold every round re-pays the full connect latency per outbox relay.
* The warm pool keeps a do-nothing subscription (never-matching filter, EOSEs
* instantly, streams nothing) open to up to this many relays for the whole
* crawl: busiest proven relays first, then next-round outbox candidates, then
* [knownLiveRelays]. Each warm relay holds one socket (one file descriptor)
* and one dormant sub — size against the process FD budget, and keep it above
* the transport's concurrent-handshake cap only if FDs allow. `<= 0` falls
* back to warming just the busiest [WARM_POOL_SIZE] relays (the old behavior).
*/
val preconnectCap: Int = 2500,
)
/** What the crawl fetched — the counters the caller reports and the graph is built from. */
@@ -484,6 +505,41 @@ class GrapeRankCrawler(
.map { it.key }
.toList()
/**
* Refresh the crawl-wide warm pool — the mass pre-connect. Re-subscribing the
* same [WARM_SUB_ID] with a new relay set just updates the desired-relay set:
* relays already warm stay connected, new ones start their handshake NOW (in
* parallel, in the background), so by the time a drain routes to them the
* socket is already open. Priority under [Config.preconnectCap]: busiest
* proven-live relays, then advertised-but-not-yet-contacted outboxes (exactly
* the relays the next rounds will dial), then the prior run's known-live
* universe. Dead relays are always excluded so the pool never redials them.
*/
fun refreshWarmPool() {
val massCap = config.preconnectCap
val cap = if (massCap > 0) massCap else WARM_POOL_SIZE
val warm = LinkedHashSet<NormalizedRelayUrl>(cap * 2)
warm.addAll(topLiveRelays(cap))
if (massCap > 0) {
if (warm.size < cap) {
// Advertised write relays not yet proven live — next drains' targets.
val advertised = writeRelayFreq.snapshot().entries.sortedByDescending { it.value }
for ((relay, _) in advertised) {
if (warm.size >= cap) break
if (!isDead(relay)) warm.add(relay)
}
}
if (warm.size < cap) {
for (relay in config.knownLiveRelays) {
if (warm.size >= cap) break
if (!isDead(relay)) warm.add(relay)
}
}
}
if (warm.isEmpty()) return
client.subscribe(WARM_SUB_ID, warm.associateWith { WARM_FILTERS }, null)
}
/**
* Background reachability culler. Cheaply TCP-probes the relays we've learned —
* COLD TAIL FIRST — and drops the unreachable ones into [deadHosts] so the WS
@@ -1425,6 +1481,19 @@ class GrapeRankCrawler(
// by scope.cancel() at crawl end.
config.reachabilityProbe?.let { probe -> scope.launch { cullUnreachable(probe) } }
// Mass pre-connect: start the handshake to every relay a prior run proved
// live NOW, in one parallel storm, so the connect wait is paid once —
// concurrently, before the first drain — instead of serially inside each
// drain that first routes to a relay. The subscribe returns immediately;
// sockets ramp in the background while round 1's discovery queries run.
if (config.preconnectCap > 0 && config.knownLiveRelays.isNotEmpty()) {
log(
"[graperank] pre-connecting up to ${config.preconnectCap} of " +
"${config.knownLiveRelays.size} known-live relays",
)
refreshWarmPool()
}
while (rounds < config.maxRounds) {
// Fold in whatever the parked (slow-but-alive) relays have delivered
// since the last round — their late contact lists expand the frontier
@@ -1453,12 +1522,11 @@ class GrapeRankCrawler(
progBaseDone = done.size
progConverging = false
// Refresh the warm pool to this round's busiest relays and keep that
// subscription open — reusing the same subId just updates the
// desired-relay set, so these sockets stay up across the round.
topLiveRelays(WARM_POOL_SIZE).takeIf { it.isNotEmpty() }?.let { warm ->
client.subscribe(WARM_SUB_ID, warm.associateWith { WARM_FILTERS }, null)
}
// Refresh the warm pool for this round and keep that subscription open —
// reusing the same subId just updates the desired-relay set, so warm
// sockets survive across rounds and newly-learned outbox relays start
// connecting in the background before Phase B routes to them.
refreshWarmPool()
val discoveredBefore = hopOf.size
val fedBefore = contactListsFed
@@ -1961,10 +2029,12 @@ class GrapeRankCrawler(
// Most-used write relays kept as the known-good backbone for retrying users.
private const val BACKBONE_SIZE = 30
// Warm pool: hold a do-nothing subscription open to the busiest relays for
// the whole crawl, so the connections we reuse every round survive the
// between-round routing gaps. The filter matches an impossible event id, so
// the relay EOSEs immediately and streams nothing — it only keeps sockets warm.
// Warm pool: hold a do-nothing subscription open to relays for the whole
// crawl, so their connections survive the between-round routing gaps. The
// filter matches an impossible event id, so the relay EOSEs immediately and
// streams nothing — it only keeps sockets warm. With Config.preconnectCap > 0
// the pool covers the whole candidate universe (mass pre-connect); this
// constant is the busiest-relays fallback size when that is disabled.
private const val WARM_POOL_SIZE = 20
private const val WARM_SUB_ID = "graperank-warm"
private val WARM_FILTERS = listOf(Filter(ids = listOf("0".repeat(64))))
@@ -0,0 +1,265 @@
/*
* 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.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
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.store.IEventStore
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeSource
/**
* Mass relay census: dials a whole relay universe in parallel waves and returns a
* per-relay [Verdict] — reachable (with the measured WebSocket-open RTT and, when the
* relay answered a no-op REQ, its time-to-EOSE) or dead (with the failure reason).
*
* The point is to pay the "is this relay alive, and how slow is it?" wait ONCE, up
* front and concurrently, instead of rediscovering it serially inside a crawl: feed
* the verdicts to [RelayReachabilityStore.recordProbed] and the next crawl skips the
* dead set entirely and pre-connects the live set in one storm.
*
* Mechanics per wave:
* - subscribe a never-matching filter (impossible event id) to every relay in the
* wave at once — the client dials them all in parallel (bounded by the transport's
* handshake concurrency), a healthy relay EOSEs immediately, and no event payload
* is ever streamed;
* - a connection listener captures the real WS-upgrade RTT per relay;
* - any app-level answer (EOSE, or even a CLOSED — an auth-walled relay is still a
* WORKING relay) marks it reachable; a connect failure marks it dead;
* - a relay with no terminal by the wave deadline is dead if it never opened the
* socket, reachable-but-slow if it did.
*
* Wave size should stay at or below the transport's concurrent-handshake cap (and
* well below the process's file-descriptor budget) — beyond that, extra relays in a
* wave just queue and eat the wave deadline without dialing.
*/
class RelayProber(
private val client: INostrClient,
private val log: (String) -> Unit = {},
) {
/** One relay's probe outcome. [rttOpenMs]/[rttEoseMs] are -1 when not observed. */
class Verdict(
val relay: NormalizedRelayUrl,
val reachable: Boolean,
val rttOpenMs: Long,
val rttEoseMs: Long,
val error: String?,
)
class Result(
val verdicts: List<Verdict>,
val elapsedMs: Long,
) {
val reachable: List<Verdict> get() = verdicts.filter { it.reachable }
val dead: List<Verdict> get() = verdicts.filter { !it.reachable }
/** Per-relay open RTT for the reachable set, 0 (= "live, latency unknown") when unobserved. */
fun reachableRttMs(): Map<NormalizedRelayUrl, Long> = reachable.associate { it.relay to it.rttOpenMs.coerceAtLeast(0) }
fun deadRelays(): Set<NormalizedRelayUrl> = dead.mapTo(HashSet()) { it.relay }
}
/**
* Probe every relay in [relays], [waveSize] at a time, giving each wave up to
* [timeoutMs] to reach terminals. Returns one [Verdict] per input relay.
*/
suspend fun probe(
relays: Collection<NormalizedRelayUrl>,
timeoutMs: Long = 15_000,
waveSize: Int = 1000,
): Result {
val mark = TimeSource.Monotonic.markNow()
val all = ArrayList<Verdict>(relays.size)
val distinct = relays.toSet()
var done = 0
for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) {
all += probeWave(wave, timeoutMs)
done += wave.size
if (distinct.size > wave.size) {
val liveSoFar = all.count { it.reachable }
log("[relay-probe] $done/${distinct.size} probed · $liveSoFar reachable")
}
}
return Result(all, mark.elapsedNow().inWholeMilliseconds)
}
private suspend fun probeWave(
wave: List<NormalizedRelayUrl>,
timeoutMs: Long,
): List<Verdict> {
val mark = TimeSource.Monotonic.markNow()
val waveSet = wave.toHashSet()
val openRtt = ConcurrentMap<NormalizedRelayUrl, Long>()
val eoseMs = ConcurrentMap<NormalizedRelayUrl, Long>()
val errors = ConcurrentMap<NormalizedRelayUrl, String>()
// Every terminal (EOSE / CLOSED / cannot-connect) pings this with its relay so
// the wait loop can stop as soon as the whole wave has resolved.
val terminals = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
val connListener =
object : RelayConnectionListener {
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
if (relay.url in waveSet) openRtt.getOrPut(relay.url) { pingMillis.toLong() }
}
}
val subId = newSubId()
val subListener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// The filter matches nothing; any stray event still proves liveness.
eoseMs.getOrPut(relay) { mark.elapsedNow().inWholeMilliseconds }
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseMs.getOrPut(relay) { mark.elapsedNow().inWholeMilliseconds }
terminals.trySend(relay)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// A CLOSED is an app-level ANSWER (auth wall, policy, …): the relay
// is working. Record why so the caller can see the wall.
errors.getOrPut(relay) { "closed:$message" }
terminals.trySend(relay)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
errors.getOrPut(relay) { "cannot:$message" }
terminals.trySend(relay)
}
}
client.addConnectionListener(connListener)
try {
client.subscribe(subId, wave.associateWith { PROBE_FILTERS }, subListener)
val remaining = wave.toMutableSet()
withTimeoutOrNull(timeoutMs) {
while (remaining.isNotEmpty()) {
remaining.remove(terminals.receive())
}
}
} finally {
client.unsubscribe(subId)
client.removeConnectionListener(connListener)
terminals.close()
}
return wave.map { relay ->
val opened = openRtt[relay]
val answered = eoseMs[relay]
val error = errors[relay]
// Reachable = the socket opened OR the relay answered at the app level
// (EOSE, or a CLOSED — an auth/policy wall is still a working relay).
// Only a connect failure, or silence with no socket, is dead.
val cannot = error?.startsWith("cannot:") == true
val reachable = !cannot && (opened != null || answered != null || error != null)
Verdict(
relay = relay,
reachable = reachable,
rttOpenMs = opened ?: -1,
rttEoseMs = answered ?: -1,
error = error,
)
}
}
companion object {
// A filter no event can match (ids are 64-hex of a hash): the relay answers
// with an immediate EOSE and never streams a payload. Same trick as the
// crawler's warm pool.
private val PROBE_FILTERS = listOf(Filter(ids = listOf("0".repeat(64))))
/**
* The relay universe the local store knows: every read/write relay advertised
* in any stored kind:10002. Callers typically union this with the reachability
* cache's live+dead sets so previously-probed relays are re-checked too.
* `.onion` relays are excluded unless [includeOnion] — without a Tor transport
* they'd only burn a wave slot. [maxPerAuthority] bounds how many distinct
* URLs are kept per host[:port]: paid/filter relays mint one path URL per user
* (`wss://filter.example/npubA`, `/npubB`, …), and probing hundreds of paths
* of ONE server is redundant (liveness is a server property) and rude.
*/
suspend fun knownRelayUniverse(
store: IEventStore,
includeOnion: Boolean = false,
maxPerAuthority: Int = 3,
): Set<NormalizedRelayUrl> {
val out = HashSet<NormalizedRelayUrl>()
val perAuthority = HashMap<String, Int>()
for (ev in store.query<Event>(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)))) {
if (ev !is AdvertisedRelayListEvent) continue
for (relay in ev.relaysNorm()) {
if (!includeOnion && RelayUrlNormalizer.isOnion(relay.url)) continue
if (relay in out) continue
val authority = authorityOf(relay.url)
val count = perAuthority[authority] ?: 0
if (count >= maxPerAuthority) continue
perAuthority[authority] = count + 1
out.add(relay)
}
}
return out
}
/** host[:port] between the ws/wss scheme and the first path slash. */
private fun authorityOf(url: String): String {
val afterScheme =
when {
url.startsWith("wss://") -> url.substring(6)
url.startsWith("ws://") -> url.substring(5)
else -> url
}
val slash = afterScheme.indexOf('/')
return if (slash >= 0) afterScheme.substring(0, slash) else afterScheme
}
}
}
@@ -142,6 +142,21 @@ class RelayReachabilityStore(
for (relay in dead) if (relay !in reachable) writeOne(relay, up = false, now, rttOpenMs)
}
/**
* Like [record], but with a real, per-relay measured open round-trip — the shape a
* dedicated probe (see RelayProber) produces. Each reachable relay's record carries
* ITS OWN `rtt-open`, so the cache doubles as a latency census: a later reader can
* separate fast relays from working-but-slow ones instead of only live from dead.
*/
suspend fun recordProbed(
reachableRttMs: Map<NormalizedRelayUrl, Long>,
dead: Set<NormalizedRelayUrl>,
now: Long = TimeUtils.now(),
) {
for ((relay, rtt) in reachableRttMs) writeOne(relay, up = true, now, rtt.coerceAtLeast(0))
for (relay in dead) if (relay !in reachableRttMs) writeOne(relay, up = false, now, 0)
}
private suspend fun writeOne(
relay: NormalizedRelayUrl,
up: Boolean,
@@ -0,0 +1,103 @@
/*
* 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.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RelayProberUniverseTest {
private fun norm(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!!
private fun relayInfo(url: String) = AdvertisedRelayInfo(norm(url), AdvertisedRelayType.BOTH)
@Test
fun collectsAdvertisedRelaysAcrossAuthors() =
runBlocking {
val store = EventStore(null)
store.insert(AdvertisedRelayListEvent.create(listOf(relayInfo("wss://a.example"), relayInfo("wss://b.example")), NostrSignerSync()))
store.insert(AdvertisedRelayListEvent.create(listOf(relayInfo("wss://b.example"), relayInfo("wss://c.example")), NostrSignerSync()))
val universe = RelayProber.knownRelayUniverse(store)
assertEquals(setOf(norm("wss://a.example"), norm("wss://b.example"), norm("wss://c.example")), universe)
store.close()
}
@Test
fun capsPathUrlsPerAuthority() =
runBlocking {
val store = EventStore(null)
// One paid/filter host advertised as one path URL per user.
val paths = (1..10).map { relayInfo("wss://filter.example/user$it") }
store.insert(AdvertisedRelayListEvent.create(paths + relayInfo("wss://solo.example"), NostrSignerSync()))
val universe = RelayProber.knownRelayUniverse(store, maxPerAuthority = 3)
assertEquals(3, universe.count { it.url.contains("filter.example") }, "per-authority URLs must be capped")
assertTrue(norm("wss://solo.example") in universe)
store.close()
}
@Test
fun onionRelaysAreExcludedByDefault() =
runBlocking {
val store = EventStore(null)
store.insert(
AdvertisedRelayListEvent.create(
listOf(relayInfo("wss://clear.example"), relayInfo("ws://someonionaddressabcdefghijklmnop.onion")),
NostrSignerSync(),
),
)
val universe = RelayProber.knownRelayUniverse(store)
assertEquals(setOf(norm("wss://clear.example")), universe)
val withOnion = RelayProber.knownRelayUniverse(store, includeOnion = true)
assertEquals(2, withOnion.size)
store.close()
}
@Test
fun recordProbedKeepsPerRelayRttAndLiveWins() =
runBlocking {
val store = EventStore(null)
val reach = RelayReachabilityStore(store, NostrSignerInternal(KeyPair()))
val fast = norm("wss://fast.example")
val slow = norm("wss://slow.example")
val dead = norm("wss://dead.example")
reach.recordProbed(mapOf(fast to 42L, slow to 9000L), setOf(dead, slow))
val snapshot = reach.snapshot()
// A relay both probed-live and reported dead stays live (live wins).
assertEquals(setOf(fast, slow), snapshot.live)
assertEquals(setOf(dead), snapshot.dead)
store.close()
}
}
@@ -0,0 +1,107 @@
/*
* 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.quartz.nip01Core.relay.sockets.okhttp
import okhttp3.Dns
import java.net.InetAddress
import java.net.UnknownHostException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
/**
* An in-process DNS cache for OkHttp that makes mass parallel connections viable:
*
* - **Positive cache** ([positiveTtlMs]): the JVM's own `InetAddress` cache holds
* entries for ~30s — useless across a 30-minute crawl that re-dials the same
* hosts every round. Resolved addresses are kept for the TTL so reconnects and
* the outbox model's many per-user path URLs on one host
* (`wss://filter.example/npubA`, `/npubB`, …) resolve instantly.
* - **Negative cache** ([negativeTtlMs]): a dead domain is the single most
* expensive lookup — glibc `getaddrinfo` retries nameservers for 1030s while
* holding an OkHttp dispatcher thread — and a crawl re-dials dead relays from
* every straggler's outbox. The first [UnknownHostException] is remembered and
* re-thrown immediately for the TTL, so later dials fail in microseconds.
* - **In-flight dedup**: concurrent lookups of the same host (a connect storm
* dialing hundreds of URLs on one authority at once) collapse onto a single
* delegate call; the rest wait on its [CompletableFuture] instead of stacking
* N identical blocking `getaddrinfo` calls.
*
* Only [UnknownHostException] is negative-cached — a resolver that *errors*
* (interrupted, SecurityException, …) is not proof the name is bad, so those
* propagate uncached. Entries are evicted lazily on the next lookup after
* expiry; the map is bounded by the distinct-host universe (a few thousand for
* a full crawl), so no active eviction is needed.
*/
class CachingDns(
private val delegate: Dns = Dns.SYSTEM,
private val positiveTtlMs: Long = 10 * 60_000L,
private val negativeTtlMs: Long = 10 * 60_000L,
private val nowMs: () -> Long = System::currentTimeMillis,
) : Dns {
private class Entry(
/** Resolved addresses, or null for a cached resolution failure. */
val addresses: List<InetAddress>?,
val expiresAtMs: Long,
)
private val cache = ConcurrentHashMap<String, Entry>()
private val inFlight = ConcurrentHashMap<String, CompletableFuture<List<InetAddress>>>()
override fun lookup(hostname: String): List<InetAddress> {
val hit = cache[hostname]
if (hit != null) {
if (nowMs() < hit.expiresAtMs) {
return hit.addresses
?: throw UnknownHostException("$hostname (cached DNS failure)")
}
cache.remove(hostname, hit)
}
// One resolver call per host at a time: the creator runs the delegate,
// everyone else who raced in blocks on the same future.
val future = CompletableFuture<List<InetAddress>>()
val existing = inFlight.putIfAbsent(hostname, future)
if (existing != null) {
return try {
existing.join()
} catch (e: Exception) {
throw (e.cause as? UnknownHostException) ?: UnknownHostException("$hostname (concurrent lookup failed)")
}
}
try {
val addresses = delegate.lookup(hostname)
cache[hostname] = Entry(addresses, nowMs() + positiveTtlMs)
future.complete(addresses)
return addresses
} catch (e: UnknownHostException) {
cache[hostname] = Entry(null, nowMs() + negativeTtlMs)
future.completeExceptionally(e)
throw e
} catch (e: Exception) {
// Not proof the name is bad (interrupt, security, …) — don't cache.
future.completeExceptionally(e)
throw e
} finally {
inFlight.remove(hostname, future)
}
}
}
@@ -0,0 +1,125 @@
/*
* 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.quartz.nip01Core.relay.sockets.okhttp
import okhttp3.Dns
import java.net.InetAddress
import java.net.UnknownHostException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class CachingDnsTest {
private val addr = listOf(InetAddress.getByAddress("good.example", byteArrayOf(10, 0, 0, 1)))
private class CountingDns(
val onLookup: (String) -> List<InetAddress>,
) : Dns {
val calls = AtomicInteger(0)
override fun lookup(hostname: String): List<InetAddress> {
calls.incrementAndGet()
return onLookup(hostname)
}
}
@Test
fun cachesPositiveLookupsUntilTtl() {
var now = 0L
val delegate = CountingDns { addr }
val dns = CachingDns(delegate, positiveTtlMs = 1000, negativeTtlMs = 1000, nowMs = { now })
assertEquals(addr, dns.lookup("good.example"))
assertEquals(addr, dns.lookup("good.example"))
assertEquals(1, delegate.calls.get(), "second lookup within TTL must be served from cache")
now = 1001
assertEquals(addr, dns.lookup("good.example"))
assertEquals(2, delegate.calls.get(), "expired entry must re-resolve")
}
@Test
fun cachesUnknownHostFailuresUntilTtl() {
var now = 0L
val delegate = CountingDns { throw UnknownHostException(it) }
val dns = CachingDns(delegate, positiveTtlMs = 1000, negativeTtlMs = 1000, nowMs = { now })
assertFailsWith<UnknownHostException> { dns.lookup("dead.example") }
assertFailsWith<UnknownHostException> { dns.lookup("dead.example") }
assertEquals(1, delegate.calls.get(), "second failing lookup within TTL must be the cached failure")
now = 1001
assertFailsWith<UnknownHostException> { dns.lookup("dead.example") }
assertEquals(2, delegate.calls.get(), "expired negative entry must re-resolve")
}
@Test
fun nonUnknownHostErrorsAreNotCached() {
val delegate = CountingDns { throw RuntimeException("resolver interrupted") }
val dns = CachingDns(delegate, nowMs = { 0 })
assertFailsWith<RuntimeException> { dns.lookup("flaky.example") }
assertFailsWith<RuntimeException> { dns.lookup("flaky.example") }
assertEquals(2, delegate.calls.get(), "a transient resolver error must not be negative-cached")
}
@Test
fun concurrentLookupsOfSameHostCollapseToOneDelegateCall() {
val started = CountDownLatch(1)
val release = CountDownLatch(1)
val delegate =
CountingDns {
started.countDown()
release.await(5, TimeUnit.SECONDS)
addr
}
val dns = CachingDns(delegate, nowMs = { 0 })
val pool = Executors.newFixedThreadPool(8)
try {
val results =
(1..8).map {
pool.submit<List<InetAddress>> {
if (it == 1) {
dns.lookup("busy.example")
} else {
// Wait until the first lookup is inside the delegate so the
// rest genuinely race against an in-flight resolution.
started.await(5, TimeUnit.SECONDS)
dns.lookup("busy.example")
}
}
}
// Give the racers a moment to pile onto the in-flight future, then release.
started.await(5, TimeUnit.SECONDS)
Thread.sleep(50)
release.countDown()
for (f in results) assertEquals(addr, f.get(5, TimeUnit.SECONDS))
assertTrue(delegate.calls.get() <= 2, "concurrent lookups should collapse (got ${delegate.calls.get()} delegate calls)")
} finally {
pool.shutdownNow()
}
}
}