feat(okhttp): concurrent caching DNS resolver

Avoids paying the getaddrinfo tax on every HTTP call to the same
host. Adds a single-flight, LRU+TTL DNS resolver wired into both the
media and relay OkHttp clients via a process-wide shared instance, so
resolutions cross-cut images, relays, and NIP-05 lookups.

- Per-host coalescing: N concurrent lookups for the same host share one
  upstream call.
- Different hosts proceed in parallel — no global lock around the
  upstream resolver.
- Negative cache (10s) prevents hammering on typos / dead hosts.
- Positive cache (5m) survives a feed scroll.
This commit is contained in:
Claude
2026-05-03 15:17:06 +00:00
parent 3554ccf4c0
commit a2102e2bec
4 changed files with 361 additions and 0 deletions
@@ -0,0 +1,155 @@
/*
* 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.service.okhttp
import okhttp3.Dns
import java.net.InetAddress
import java.net.UnknownHostException
import java.util.Collections
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
/**
* Concurrent, caching DNS resolver for OkHttp.
*
* The system resolver call ([InetAddress.getAllByName] used by [Dns.SYSTEM]) is a blocking JNI
* hop into `getaddrinfo`. On a busy feed we may issue dozens of HTTP calls to a handful of hosts
* in the same second; the default behaviour pays the resolver tax once per call and serializes
* the OkHttp dispatcher worker that asked for it.
*
* This resolver adds three things on top of the system resolver:
*
* 1. An LRU + TTL cache, so repeated lookups of the same host short-circuit before touching the
* network. Negative results get a short TTL so a typo doesn't keep hammering DNS.
* 2. Single-flight coalescing: when N OkHttp threads ask for the same host concurrently, only
* one of them performs the upstream lookup. The others block on the same future and pick up
* the result. Without this, ten parallel image requests to the same CDN make ten DNS calls.
* 3. No global lock on the slow path: lookups for *different* hosts proceed in parallel because
* the upstream resolver is invoked outside any monitor.
*/
class AmethystDns(
private val delegate: Dns = Dns.SYSTEM,
private val maxEntries: Int = 256,
positiveTtlMs: Long = TimeUnit.MINUTES.toMillis(5),
negativeTtlMs: Long = TimeUnit.SECONDS.toMillis(10),
) : Dns {
private val positiveTtlNanos = TimeUnit.MILLISECONDS.toNanos(positiveTtlMs)
private val negativeTtlNanos = TimeUnit.MILLISECONDS.toNanos(negativeTtlMs)
private val cache: MutableMap<String, Entry> =
Collections.synchronizedMap(
object : LinkedHashMap<String, Entry>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, Entry>): Boolean = size > maxEntries
},
)
private val inflight = ConcurrentHashMap<String, CompletableFuture<List<InetAddress>>>()
override fun lookup(hostname: String): List<InetAddress> {
cache[hostname]?.let { entry ->
if (entry.expiresAtNanos > System.nanoTime()) {
return entry.unwrap(hostname)
}
}
val newFuture = CompletableFuture<List<InetAddress>>()
val existing = inflight.putIfAbsent(hostname, newFuture)
return if (existing == null) {
resolveAsLeader(hostname, newFuture)
} else {
awaitFollower(hostname, existing)
}
}
private fun resolveAsLeader(
hostname: String,
future: CompletableFuture<List<InetAddress>>,
): List<InetAddress> {
try {
val addresses = delegate.lookup(hostname)
put(hostname, addresses, positiveTtlNanos)
future.complete(addresses)
return addresses
} catch (e: UnknownHostException) {
put(hostname, emptyList(), negativeTtlNanos)
future.completeExceptionally(e)
throw e
} catch (e: Throwable) {
future.completeExceptionally(e)
throw e
} finally {
inflight.remove(hostname, future)
}
}
private fun awaitFollower(
hostname: String,
future: CompletableFuture<List<InetAddress>>,
): List<InetAddress> {
try {
val addresses = future.get()
return addresses.ifEmpty { throw UnknownHostException(hostname) }
} catch (e: ExecutionException) {
when (val cause = e.cause) {
is UnknownHostException -> throw cause
null -> throw UnknownHostException(hostname)
else -> throw UnknownHostException(hostname).apply { initCause(cause) }
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw UnknownHostException(hostname).apply { initCause(e) }
}
}
private fun put(
hostname: String,
addresses: List<InetAddress>,
ttlNanos: Long,
) {
cache[hostname] = Entry(addresses, System.nanoTime() + ttlNanos)
}
/** Drop all cached entries. Call when the network changes (e.g. WiFi <-> mobile). */
fun invalidate() {
cache.clear()
}
/** Drop a single host's cached entry. */
fun invalidate(hostname: String) {
cache.remove(hostname)
}
private class Entry(
val addresses: List<InetAddress>,
val expiresAtNanos: Long,
) {
fun unwrap(hostname: String): List<InetAddress> = addresses.ifEmpty { throw UnknownHostException(hostname) }
}
companion object {
/**
* Process-wide instance shared by every OkHttp client built in the app, so a host resolved
* for an image fetch is reused when a relay handshake or NIP-05 lookup hits the same host.
*/
val shared: AmethystDns by lazy { AmethystDns() }
}
}
@@ -63,6 +63,7 @@ class OkHttpClientFactory(
.Builder()
.dispatcher(dispatcher)
.connectionPool(connectionPool)
.dns(AmethystDns.shared)
.eventListenerFactory(MediaCallEventListenerFactory(dispatcher, connectionPool))
.followRedirects(true)
.followSslRedirects(true)
@@ -55,6 +55,7 @@ class OkHttpClientFactoryForRelays(
OkHttpClient
.Builder()
.dispatcher(myDispatcher)
.dns(AmethystDns.shared)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
@@ -0,0 +1,204 @@
/*
* 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.service.okhttp
import okhttp3.Dns
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
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
class AmethystDnsTest {
private fun ip(value: String) = InetAddress.getByName(value)
private class CountingDns(
private val responses: Map<String, List<InetAddress>>,
) : Dns {
val callsByHost = mutableMapOf<String, AtomicInteger>()
override fun lookup(hostname: String): List<InetAddress> {
callsByHost.getOrPut(hostname) { AtomicInteger() }.incrementAndGet()
return responses[hostname] ?: throw UnknownHostException(hostname)
}
fun calls(hostname: String): Int = callsByHost[hostname]?.get() ?: 0
}
private class GatedDns(
private val responses: Map<String, List<InetAddress>>,
) : Dns {
val started = CountDownLatch(1)
val release = CountDownLatch(1)
val calls = AtomicInteger()
override fun lookup(hostname: String): List<InetAddress> {
calls.incrementAndGet()
started.countDown()
release.await()
return responses[hostname] ?: throw UnknownHostException(hostname)
}
}
@Test
fun `cache hit avoids second upstream call`() {
val upstream = CountingDns(mapOf("a.example" to listOf(ip("1.2.3.4"))))
val dns = AmethystDns(delegate = upstream)
val first = dns.lookup("a.example")
val second = dns.lookup("a.example")
assertEquals(listOf(ip("1.2.3.4")), first)
assertSame(first, second)
assertEquals(1, upstream.calls("a.example"))
}
@Test
fun `negative cache short-circuits subsequent lookups`() {
val upstream = CountingDns(emptyMap())
val dns = AmethystDns(delegate = upstream)
assertThrows(UnknownHostException::class.java) { dns.lookup("missing.example") }
assertThrows(UnknownHostException::class.java) { dns.lookup("missing.example") }
assertEquals(1, upstream.calls("missing.example"))
}
@Test
fun `positive entries expire`() {
val upstream = CountingDns(mapOf("a.example" to listOf(ip("1.2.3.4"))))
val dns =
AmethystDns(
delegate = upstream,
positiveTtlMs = 1,
negativeTtlMs = 1,
)
dns.lookup("a.example")
Thread.sleep(20)
dns.lookup("a.example")
assertEquals(2, upstream.calls("a.example"))
}
@Test
fun `concurrent lookups for the same host coalesce to one upstream call`() {
val gated = GatedDns(mapOf("hot.example" to listOf(ip("9.9.9.9"))))
val dns = AmethystDns(delegate = gated)
val pool = Executors.newFixedThreadPool(8)
try {
val results = (1..8).map { pool.submit<List<InetAddress>> { dns.lookup("hot.example") } }
assertTrue(
"Leader should have started the upstream lookup",
gated.started.await(2, TimeUnit.SECONDS),
)
gated.release.countDown()
results.forEach {
assertEquals(listOf(ip("9.9.9.9")), it.get(2, TimeUnit.SECONDS))
}
assertEquals(1, gated.calls.get())
} finally {
pool.shutdownNow()
}
}
@Test
fun `lookups for different hosts run in parallel`() {
val responses = mapOf("a" to listOf(ip("1.1.1.1")), "b" to listOf(ip("2.2.2.2")))
val parallelism = AtomicInteger()
val peak = AtomicInteger()
val release = CountDownLatch(1)
val instrumented =
Dns { hostname ->
val now = parallelism.incrementAndGet()
peak.updateAndGet { maxOf(it, now) }
try {
release.await(2, TimeUnit.SECONDS)
responses[hostname] ?: throw UnknownHostException(hostname)
} finally {
parallelism.decrementAndGet()
}
}
val dns = AmethystDns(delegate = instrumented)
val pool = Executors.newFixedThreadPool(2)
try {
val futureA = pool.submit<List<InetAddress>> { dns.lookup("a") }
val futureB = pool.submit<List<InetAddress>> { dns.lookup("b") }
// Wait briefly for both threads to enter the resolver, then let them out.
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2)
while (peak.get() < 2 && System.nanoTime() < deadline) {
Thread.sleep(5)
}
release.countDown()
futureA.get(2, TimeUnit.SECONDS)
futureB.get(2, TimeUnit.SECONDS)
assertEquals("Both hosts should resolve concurrently", 2, peak.get())
} finally {
pool.shutdownNow()
}
}
@Test
fun `invalidate clears cache so next lookup hits upstream`() {
val upstream = CountingDns(mapOf("a.example" to listOf(ip("1.2.3.4"))))
val dns = AmethystDns(delegate = upstream)
dns.lookup("a.example")
dns.invalidate()
dns.lookup("a.example")
assertEquals(2, upstream.calls("a.example"))
}
@Test
fun `invalidate by host removes only that entry`() {
val upstream =
CountingDns(
mapOf(
"a.example" to listOf(ip("1.2.3.4")),
"b.example" to listOf(ip("5.6.7.8")),
),
)
val dns = AmethystDns(delegate = upstream)
dns.lookup("a.example")
dns.lookup("b.example")
dns.invalidate("a.example")
dns.lookup("a.example")
dns.lookup("b.example")
assertEquals(2, upstream.calls("a.example"))
assertEquals(1, upstream.calls("b.example"))
}
}