Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
package okhttp3.internal.dns

import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
import kotlin.time.ComparableTimeMark as Time
import kotlin.time.Duration
Expand All @@ -29,12 +28,13 @@ import okhttp3.internal.OkHttpInternalApi
import okhttp3.internal.concurrent.TaskRunner
import okhttp3.internal.dns.StateMachineDnsCall.Transport

// TODO: evict old entries from cache using State.lastRequestedAt

/**
* A DNS transport that caches responses according to their [ResourceRecord.timeToLive], bounded by
* a user-supplied minimum and maximum cache duration.
*
* 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
Expand All @@ -51,6 +51,18 @@ import okhttp3.internal.dns.StateMachineDnsCall.Transport
*
* 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.
Expand All @@ -62,8 +74,28 @@ class CachingTransport<Q>(
private val maximumTimeToLive: Duration = 300.seconds,
private val failureTimeToLive: Duration = 10.seconds,
private val revalidateBeforeExpire: Duration = 5.seconds,
maxEntryCount: Int = 1000,
) : Transport<CachingTransport.Query<Q>> {
private val entries = ConcurrentHashMap<Question, Entry>()
private val cache =
object : MemoryCache<Question, Entry>(
timeSource = timeSource,
maxSize = maxEntryCount,
) {
override fun lastRequestedAt(
now: Time,
value: CachingTransport<Q>.Entry,
): Time? {
val state = value.state.get()

// If it's already expired, evict immediately.
if (state.inFlightCall == null) {
val expireAt = state.result?.expireAt ?: return null
if (expireAt <= now) return null
}

return state.lastRequestedAt
}
}

init {
require(failureTimeToLive >= 0.seconds)
Expand All @@ -73,8 +105,7 @@ class CachingTransport<Q>(
}

override fun newQuery(question: Question): Query<Q> {
val inserted = Entry(question)
val entry = entries.putIfAbsent(question, inserted) ?: inserted
val entry = cache.computeIfAbsent(question) { Entry(question) }
return Query(entry)
}

Expand Down
125 changes: 125 additions & 0 deletions okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/MemoryCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* 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.
*/
package okhttp3.internal.dns

import java.util.PriorityQueue
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.ComparableTimeMark as Time
import kotlin.time.TimeSource

/**
* This cache is configured with a target [maxSize], but it will not evict entries until it
* has twice as many entries.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also considered evicting whenever we were 25% greater than the target. Both 1.25x and 2x have the same big-O runtime. 1.25x is better on memory, 2.x is better on CPU.

*
* This retains entries in least-recently-used order.
*
* This evicts in big batches because each eviction must traverse the entire cache.
*/
abstract class MemoryCache<K : Any, V : Any>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a way to mark global changes that would invalidate the whole cache?

Let's say the default network changes, we should not used cached values.

I think for network selection we can have different caches. So it's mainly for global changes

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, can do. evictAll() !

private val timeSource: TimeSource.WithComparableMarks,
val maxSize: Int,
) {
private val entries = ConcurrentHashMap<K, V>()

init {
require(maxSize >= 0)
}

/**
* Returns the time this value was most recently used, in order to make an eviction decision. This
* should return null if this element should be evicted immediately.
*/
abstract fun lastRequestedAt(
now: Time,
value: V,
): Time?

/**
* Similar to [ConcurrentHashMap.computeIfAbsent], but this will also prune the cache to size if
* this function grows the cache to double [maxSize].
*/
fun computeIfAbsent(
key: K,
computeValue: () -> V,
): V {
if (maxSize == 0) return computeValue()

val result = entries[key]
if (result != null) return result

val created = computeValue()
val existing = entries.putIfAbsent(key, created)
if (existing != null) return existing

// If there's more than 2x as many entries as the max, automatically evict.
val toEvict = entries.size - maxSize
if (toEvict >= maxSize) {
evict(toEvict)
}

return created
}

/**
* Manually evict [count] entries from this cache.
*
* If the cache is smaller than [count], this evicts everything.
*
* Entries that are expired are evicted unconditionally, even if this causes more than [count]
* entries to be evicted. If [count] or more expired entries are evicted, non-expired entries will
* not be evicted.
*/
fun evict(count: Int) {
// Note that we call lastRequestedAt() exactly once for each entry. If we called it on-demand in
// the comparator, we'd break the PriorityQueue's invariant that the sort order is stable.
var count = count
val now = timeSource.markNow()
val entriesToEvict = PriorityQueue<Pair<Time, Map.Entry<K, V>>>(count + 1, NewestFirst)
val i = entries.entries.iterator()
while (i.hasNext()) {
val entry = i.next()

when (val lastRequestedAt = lastRequestedAt(now, entry.value)) {
// It's expired, evict unconditionally.
null -> {
i.remove()
count--
}

// It's not expired. Make it a candidate for eviction.
else -> {
entriesToEvict.add(lastRequestedAt to entry)
}
}

// If we're above our target eviction count, save the newest entry from eviction.
if (entriesToEvict.size > count) {
entriesToEvict.poll()
}
}

for ((_, entry) in entriesToEvict) {
entries.remove(entry.key, entry.value)
}
}

private object NewestFirst : Comparator<Pair<Time, *>> {
override fun compare(
o1: Pair<Time, *>,
o2: Pair<Time, *>,
) = o2.first.compareTo(o1.first)
}
}
Loading
Loading