From 52d695da7a1b8fd373d8a4c8b800fc729ec792ff Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Fri, 24 Jul 2026 11:05:57 -0400 Subject: [PATCH] Public API for DnsCache --- okhttp/api/android/okhttp.api | 16 ++ okhttp/api/jvm/okhttp.api | 16 ++ .../kotlin/okhttp3/DnsCache.kt | 149 ++++++++++++++++++ .../okhttp3/internal/dns/MemoryCache.kt | 7 + .../dns/{DnsCache.kt => RealDnsCache.kt} | 73 ++++----- .../okhttp3/internal/dns/MemoryCacheTest.kt | 10 ++ .../internal/dns/StateMachineDnsCallTest.kt | 137 ++++++++++++++++ .../internal/dns/StateMachineDnsCallTester.kt | 6 +- 8 files changed, 368 insertions(+), 46 deletions(-) create mode 100644 okhttp/src/commonJvmAndroid/kotlin/okhttp3/DnsCache.kt rename okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/{DnsCache.kt => RealDnsCache.kt} (82%) diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index 306a46733851..aa380ae04447 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -537,6 +537,22 @@ public final class okhttp3/Dns$Request { public fun toString ()Ljava/lang/String; } +public final class okhttp3/DnsCache { + public final field -delegate Lokhttp3/internal/dns/RealDnsCache; + public synthetic fun (JJJJIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun evictAll ()V + public final fun failureTimeToLive ()J + public final fun hitCount ()I + public final fun maxSize ()I + public final fun maximumTimeToLive ()J + public final fun minimumTimeToLive ()J + public final fun networkCount ()I + public final fun requestCount ()I + public final fun revalidateBeforeExpire ()J + public final fun size ()I +} + public abstract class okhttp3/EventListener { public static final field Companion Lokhttp3/EventListener$Companion; public static final field NONE Lokhttp3/EventListener; diff --git a/okhttp/api/jvm/okhttp.api b/okhttp/api/jvm/okhttp.api index c7f6ecfed025..e01fecde1972 100644 --- a/okhttp/api/jvm/okhttp.api +++ b/okhttp/api/jvm/okhttp.api @@ -537,6 +537,22 @@ public final class okhttp3/Dns$Request { public fun toString ()Ljava/lang/String; } +public final class okhttp3/DnsCache { + public final field -delegate Lokhttp3/internal/dns/RealDnsCache; + public synthetic fun (JJJJIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun evictAll ()V + public final fun failureTimeToLive ()J + public final fun hitCount ()I + public final fun maxSize ()I + public final fun maximumTimeToLive ()J + public final fun minimumTimeToLive ()J + public final fun networkCount ()I + public final fun requestCount ()I + public final fun revalidateBeforeExpire ()J + public final fun size ()I +} + public abstract class okhttp3/EventListener { public static final field Companion Lokhttp3/EventListener$Companion; public static final field NONE Lokhttp3/EventListener; diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/DnsCache.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/DnsCache.kt new file mode 100644 index 000000000000..2b09f94f2c87 --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/DnsCache.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(OkHttpInternalApi::class) +@file:Suppress("PropertyName") // Hide the '-delegate' field from Java language users. + +package okhttp3 + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource +import okhttp3.internal.OkHttpInternalApi +import okhttp3.internal.concurrent.TaskRunner +import okhttp3.internal.dns.RealDnsCache + +/** + * An in-memory DNS query cache to save time and bandwidth. + * + * Cache Lifetime and Expiration + * ----------------------------- + * + * DNS resource records include a time to live (TTL) lifetime for returned records. This class + * honors that setting, but with bounds: + * + * * Records with a time to live lower than [minimumTimeToLive] will be used for + * [minimumTimeToLive]. The default value of this is 10 seconds. + * + * * Records with a time to live greater than [maximumTimeToLive] will be used for + * [maximumTimeToLive]. The default value of this is 300 seconds (5 minutes). + * + * If there is a failure fetching DNS records, or if no DNS records are returned, this failure is + * also cached for [failureTimeToLive]. The default value of this is 10 seconds. + * + * Revalidation + * ------------ + * + * If a cached record will expire soon, the cached record is returned immediately, but this cache + * will also asynchronously update the value for the benefit of future requests. + * + * This way if a steady stream of requests are made for the same hostname, none of the requests will + * have to wait for DNS. + * + * The default value of [revalidateBeforeExpire] is 2 seconds. + * + * Request Merging + * --------------- + * + * If the cache receives multiple equivalent queries, it combines them into a single query on the + * underlying transport. + * + * Memory Usage + * ------------ + * + * This cache retains [maxSize] entries. It prefers to evict expired entries, and when that is + * insufficient, it evicts the least recently requested entries. + * + * The default [maxSize] is 1,000 entries. Most hostnames will require 3 entries for DNS record + * types `A` (IPv4), `AAAA` (IPv6), and `HTTPS` (service metadata). + * + * Between evictions, the cache may grow to double this configured limit. Each entry consumes about + * 400 bytes of memory. In total, the default cache will use about 800 KiB of memory when it is + * full. + */ +class DnsCache internal constructor( + @OkHttpInternalApi + @JvmField + val `-delegate`: RealDnsCache, +) { + @get:JvmName("minimumTimeToLive") + val minimumTimeToLive: Duration + get() = `-delegate`.minimumTimeToLive + + @get:JvmName("maximumTimeToLive") + val maximumTimeToLive: Duration + get() = `-delegate`.maximumTimeToLive + + @get:JvmName("failureTimeToLive") + val failureTimeToLive: Duration + get() = `-delegate`.failureTimeToLive + + @get:JvmName("revalidateBeforeExpire") + val revalidateBeforeExpire: Duration + get() = `-delegate`.revalidateBeforeExpire + + @get:JvmName("maxSize") + val maxSize: Int + get() = `-delegate`.maxSize + + @get:JvmName("size") + val size: Int + get() = `-delegate`.size + + /** Returns the number of calls made to the DNS server. */ + @get:JvmName("networkCount") + val networkCount: Int + get() = `-delegate`.networkCount + + /** Returns the number of DNS calls served by the cache. */ + @get:JvmName("hitCount") + val hitCount: Int + get() = `-delegate`.hitCount + + /** + * Returns the total number of DNS calls handled by this cache. This may be greater than the + * sum of [networkCount] and [hitCount] because revalidation calls do both. + */ + @get:JvmName("requestCount") + val requestCount: Int + get() = `-delegate`.requestCount + + constructor( + minimumTimeToLive: Duration = 10.seconds, + maximumTimeToLive: Duration = 300.seconds, + failureTimeToLive: Duration = 10.seconds, + revalidateBeforeExpire: Duration = 2.seconds, + maxEntryCount: Int = 1000, + ) : this( + `-delegate` = + RealDnsCache( + taskRunner = TaskRunner.INSTANCE, + timeSource = TimeSource.Monotonic, + minimumTimeToLive = minimumTimeToLive, + maximumTimeToLive = maximumTimeToLive, + failureTimeToLive = failureTimeToLive, + revalidateBeforeExpire = revalidateBeforeExpire, + maxEntryCount = maxEntryCount, + ), + ) + + /** + * Deletes all values stored in the cache. In-flight writes to the cache will complete normally, + * but the corresponding responses will not be stored. + */ + fun evictAll() { + `-delegate`.evictAll() + } +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/MemoryCache.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/MemoryCache.kt index 6447a43a8849..f54fa31a48d8 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/MemoryCache.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/MemoryCache.kt @@ -32,6 +32,9 @@ abstract class MemoryCache( val timeSource: TimeSource.WithComparableMarks, val maxSize: Int, ) { + val size: Int + get() = entries.size + private val entries = ConcurrentHashMap() init { @@ -73,6 +76,10 @@ abstract class MemoryCache( return created } + fun evictAll() { + entries.clear() + } + /** * Manually evict [count] entries from this cache. * diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/DnsCache.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/RealDnsCache.kt similarity index 82% rename from okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/DnsCache.kt rename to okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/RealDnsCache.kt index 15c39192e090..eb95b73a5cf3 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/DnsCache.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/RealDnsCache.kt @@ -18,6 +18,7 @@ package okhttp3.internal.dns import java.io.IOException +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.time.ComparableTimeMark as Time import kotlin.time.Duration @@ -27,53 +28,16 @@ import kotlin.time.TimeSource import okhttp3.internal.OkHttpInternalApi import okhttp3.internal.concurrent.TaskRunner -/** - * A DNS query cache that stores responses according to their [ResourceRecord.timeToLive]. Each - * entry's lifetime is bounded between [minimumTimeToLive] and [maximumTimeToLive]. The cache's size - * is bounded by a [maxEntryCount]. - * - * Cache Hits - * ---------- - * - * The age of the result impacts how queries are satisfied: - * - * * After [Result.expireAt], the cached result is not used and a call to the underlying transport - * is made. - * - * * After [Result.revalidateAt], the cached result is returned immediately. A call to the - * underlying transport is also made, in order to freshen the cache for a possible future call. - * - * * Otherwise, the cached data is returned immediately. - * - * Failures are cached to prevent error cases from using more resources than success cases. There's - * no server-provided defaults for these so the configuration parameter [failureTimeToLive] must be - * used. - * - * If this receives multiple equivalent queries, it combines them into a single query on the - * underlying transport. - * - * Memory Usage - * ------------ - * - * By default, this retains the 1,000 most recently accessed entries. Most hostnames will require 3 - * entries (`TYPE_A`, `TYPE_AAAA`, and `TYPE_HTTPS`). - * - * Between evictions the memory cache will grow to double that max, 2,000 entries. - * - * Each entry consumes about 400 bytes of memory. - * - * In total, the default cache will use about 800 KiB of memory. - */ @OkHttpInternalApi @OptIn(ExperimentalTime::class) // We know Clock and Instant will be stable in Kotlin 2.3. -class DnsCache( +class RealDnsCache( private val taskRunner: TaskRunner, timeSource: TimeSource.WithComparableMarks, - private val minimumTimeToLive: Duration = 10.seconds, - private val maximumTimeToLive: Duration = 300.seconds, - private val failureTimeToLive: Duration = 10.seconds, - private val revalidateBeforeExpire: Duration = 5.seconds, - maxEntryCount: Int = 1000, + internal val minimumTimeToLive: Duration, + internal val maximumTimeToLive: Duration, + internal val failureTimeToLive: Duration, + internal val revalidateBeforeExpire: Duration, + maxEntryCount: Int, ) { private val cache = object : MemoryCache( @@ -96,6 +60,21 @@ class DnsCache( } } + private val atomicNetworkCount = AtomicInteger() + private val atomicHitCount = AtomicInteger() + private val atomicRequestCount = AtomicInteger() + + internal val size: Int + get() = cache.size + internal val maxSize: Int + get() = cache.maxSize + internal val networkCount: Int + get() = atomicNetworkCount.get() + internal val hitCount: Int + get() = atomicHitCount.get() + internal val requestCount: Int + get() = atomicRequestCount.get() + init { require(failureTimeToLive >= 0.seconds) require(minimumTimeToLive >= 0.seconds) @@ -103,6 +82,10 @@ class DnsCache( require(revalidateBeforeExpire >= 0.seconds) } + fun evictAll() { + cache.evictAll() + } + fun wrap(delegate: DnsQuery.Factory) = DnsQuery.Factory { question -> val entry = cache.computeIfAbsent(question) { Entry() } @@ -165,11 +148,15 @@ class DnsCache( if (!entry.state.compareAndSet(previous, next)) continue // Lost a race, retry. + atomicRequestCount.incrementAndGet() + if (inFlightCall == null && next.inFlightCall != null) { + atomicNetworkCount.incrementAndGet() next.inFlightCall.query.enqueue(this) } if (useCached) { + atomicHitCount.incrementAndGet() taskRunner.newQueue().execute("${question.name} dns") { when (result) { is Result.Success -> callback.onResponse(result.message) diff --git a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/MemoryCacheTest.kt b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/MemoryCacheTest.kt index be719c1771b9..22e7b90c71db 100644 --- a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/MemoryCacheTest.kt +++ b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/MemoryCacheTest.kt @@ -74,6 +74,16 @@ class MemoryCacheTest { assertThat(a0).isSameInstanceAs(r1) } + @Test + fun `evictAll evicts all`() { + cache.access("a") + cache.access("b") + + cache.evictAll() + cache.assertAbsent("a") + cache.assertAbsent("b") + } + @Test fun `evict removes expired records`() { cache.access("b") diff --git a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTest.kt b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTest.kt index 2d4feea1f822..6cbcd6a67f46 100644 --- a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTest.kt +++ b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTest.kt @@ -70,6 +70,13 @@ class StateMachineDnsCallTest { last = true, alpnIds = listOf(Protocol.HTTP_2), ) + + if (caching) { + assertThat(cache.size).isEqualTo(3) + assertThat(cache.requestCount).isEqualTo(3) + assertThat(cache.networkCount).isEqualTo(3) + assertThat(cache.hitCount).isEqualTo(0) + } } } @@ -129,6 +136,11 @@ class StateMachineDnsCallTest { commonhausCall1.enqueue() assertThat(commonhausCall1.takeAllRecords().addresses()) .isEqualTo(greenIpv4s) + + assertThat(cache.size).isEqualTo(2) + assertThat(cache.requestCount).isEqualTo(4) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(2) } } @@ -164,6 +176,55 @@ class StateMachineDnsCallTest { call1.enqueue() assertThat(call1.takeAllRecords().addresses()) .isEqualTo(blueIpv6s + blueIpv4s) + + assertThat(cache.size).isEqualTo(2) + assertThat(cache.requestCount).isEqualTo(4) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(2) + } + + @Test + fun `cache evictAll causes misses`() = + testStateMachineDnsCall { + val call0 = + newCall( + request = Dns.Request(hostname = "lysine.dev"), + includeIPv6 = false, + includeServiceMetadata = false, + caching = true, + ) + call0.enqueue() + + queryFactory.respondToQuery( + hostname = "lysine.dev", + type = TYPE_A, + addresses = blueIpv4s, + ) + assertThat(call0.takeAllRecords().addresses()) + .isEqualTo(blueIpv4s) + + cache.evictAll() + + val call1 = + newCall( + request = Dns.Request(hostname = "lysine.dev"), + includeIPv6 = false, + includeServiceMetadata = false, + caching = true, + ) + call1.enqueue() + queryFactory.respondToQuery( + hostname = "lysine.dev", + type = TYPE_A, + addresses = greenIpv4s, + ) + assertThat(call1.takeAllRecords().addresses()) + .isEqualTo(greenIpv4s) + + assertThat(cache.size).isEqualTo(1) + assertThat(cache.requestCount).isEqualTo(2) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(0) } @Test @@ -215,6 +276,11 @@ class StateMachineDnsCallTest { ) assertThat(call2.takeAllRecords().addresses()) .isEqualTo(greenIpv4s) + + assertThat(cache.size).isEqualTo(1) + assertThat(cache.requestCount).isEqualTo(3) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(1) } /** @@ -259,6 +325,11 @@ class StateMachineDnsCallTest { ) assertThat(call1.takeAllRecords().addresses()) .isEqualTo(greenIpv4s) + + assertThat(cache.size).isEqualTo(1) + assertThat(cache.requestCount).isEqualTo(2) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(0) } @Test @@ -293,6 +364,11 @@ class StateMachineDnsCallTest { call1.enqueue() assertThat(call1.takeAllRecords().addresses()) .isEqualTo(blueIpv4s) + + assertThat(cache.size).isEqualTo(1) + assertThat(cache.requestCount).isEqualTo(2) + assertThat(cache.networkCount).isEqualTo(1) + assertThat(cache.hitCount).isEqualTo(1) } @Test @@ -333,6 +409,11 @@ class StateMachineDnsCallTest { ) assertThat(call1.takeAllRecords().addresses()) .isEqualTo(greenIpv4s) + + assertThat(cache.size).isEqualTo(1) + assertThat(cache.requestCount).isEqualTo(2) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(0) } /** Confirm that two queries to the cache yield a single query to the underlying transport. */ @@ -379,6 +460,13 @@ class StateMachineDnsCallTest { last = true, addresses = blueIpv4s, ) + + assertThat(cache.size).isEqualTo(2) + assertThat(cache.requestCount).isEqualTo(4) + assertThat(cache.networkCount).isEqualTo(2) + + // We don't consider it a cache hit if the caller needs to wait for revalidation. + assertThat(cache.hitCount).isEqualTo(0) } @Test @@ -441,6 +529,11 @@ class StateMachineDnsCallTest { call2.enqueue() assertThat(call2.takeAllRecords().addresses()) .isEqualTo(greenIpv6s + greenIpv4s) + + assertThat(cache.size).isEqualTo(2) + assertThat(cache.requestCount).isEqualTo(6) + assertThat(cache.networkCount).isEqualTo(4) + assertThat(cache.hitCount).isEqualTo(4) } @Test @@ -515,6 +608,11 @@ class StateMachineDnsCallTest { last = true, addresses = greenIpv6s, ) + + assertThat(cache.size).isEqualTo(2) + assertThat(cache.requestCount).isEqualTo(6) + assertThat(cache.networkCount).isEqualTo(4) + assertThat(cache.hitCount).isEqualTo(3) } @Test @@ -548,6 +646,13 @@ class StateMachineDnsCallTest { ) call.takeOnFailure("boom!") + + if (caching) { + assertThat(cache.size).isEqualTo(3) + assertThat(cache.requestCount).isEqualTo(3) + assertThat(cache.networkCount).isEqualTo(3) + assertThat(cache.hitCount).isEqualTo(0) + } } @Test @@ -586,6 +691,11 @@ class StateMachineDnsCallTest { addresses = blueIpv4s, ) call1.takeOnFailure("boom!") + + assertThat(cache.size).isEqualTo(2) + assertThat(cache.requestCount).isEqualTo(4) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(2) } @Test @@ -621,6 +731,11 @@ class StateMachineDnsCallTest { ) assertThat(call1.takeAllRecords().addresses()) .isEqualTo(greenIpv4s) + + assertThat(cache.size).isEqualTo(1) + assertThat(cache.requestCount).isEqualTo(2) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(0) } @Test @@ -667,6 +782,11 @@ class StateMachineDnsCallTest { call2.enqueue() assertThat(call2.takeAllRecords().addresses()) .isEqualTo(blueIpv4s) + + assertThat(cache.size).isEqualTo(1) + assertThat(cache.requestCount).isEqualTo(3) + assertThat(cache.networkCount).isEqualTo(2) + assertThat(cache.hitCount).isEqualTo(2) } /** @@ -712,6 +832,13 @@ class StateMachineDnsCallTest { InetAddress.getByName("1:2::3:4"), ), ) + + if (caching) { + assertThat(cache.size).isEqualTo(3) + assertThat(cache.requestCount).isEqualTo(3) + assertThat(cache.networkCount).isEqualTo(3) + assertThat(cache.hitCount).isEqualTo(0) + } } /** @@ -763,6 +890,11 @@ class StateMachineDnsCallTest { query2.respondFailure("canceled") call.takeOnFailure("canceled") + + assertThat(cache.size).isEqualTo(3) + assertThat(cache.requestCount).isEqualTo(3) + assertThat(cache.networkCount).isEqualTo(3) + assertThat(cache.hitCount).isEqualTo(0) } /** Cancels are asynchronous and if the canceled query completes anyway, that's fine. */ @@ -840,5 +972,10 @@ class StateMachineDnsCallTest { alpnIds = listOf("h2"), ) call.takeOnFailure("canceled") + + assertThat(cache.size).isEqualTo(3) + assertThat(cache.requestCount).isEqualTo(3) + assertThat(cache.networkCount).isEqualTo(3) + assertThat(cache.hitCount).isEqualTo(0) } } diff --git a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTester.kt b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTester.kt index eaaeca276951..deee6de212f2 100644 --- a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTester.kt +++ b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/StateMachineDnsCallTester.kt @@ -44,8 +44,8 @@ class StateMachineDnsCallTester internal constructor() { private val taskFaker = TaskFaker() - private val dnsCache = - DnsCache( + val cache = + RealDnsCache( taskRunner = taskFaker.taskRunner, timeSource = taskFaker.timeSource, minimumTimeToLive = 10.seconds, @@ -149,7 +149,7 @@ class StateMachineDnsCallTester internal constructor() { request = request, queryFactory = when { - caching -> dnsCache.wrap(queryFactory) + caching -> cache.wrap(queryFactory) else -> queryFactory }, canceledException = null,