feat(okhttp): persist DNS cache across process restarts
Cold starts were the resolver's worst case: every host paid a sync getaddrinfo. With a persisted snapshot, every previously-seen host falls into the stale-while-revalidate path on first lookup — the cached IP is served immediately and a background refresh updates it. ~700 blocking system calls at app start become zero. Switch entry expiries from System.nanoTime to System.currentTimeMillis so timestamps survive process death (nanoTime is monotonic per process, undefined across restarts). Add snapshot()/restore() that serialize only fresh positive entries — negative entries are skipped and re-resolved synchronously, and restore() uses putIfAbsent so a fresh in-memory entry is never clobbered by a stale on-disk one. Add AmethystDnsStore as a thin SharedPreferences + Jackson wrapper. The blob is plain (not encrypted): hostnames are already exposed in the user's signed relay list, Coil's image cache, and the system resolver's own state. Wire load + save into AppModules: - load() runs once at app start on the IO scope. - save() runs every 5 minutes (skipped when nothing has changed via the dirty flag), on trim() when the app backgrounds, and once on terminate() as a best-effort flush.
This commit is contained in:
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
|
||||
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
|
||||
import com.vitorpamplona.amethyst.service.okhttp.AmethystDnsStore
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
@@ -201,6 +202,11 @@ class AppModules(
|
||||
// Key cache service to download and decrypt encrypted files before caching them.
|
||||
val keyCache = EncryptionKeyCache()
|
||||
|
||||
// Persists the shared DNS resolver's positive cache across process restarts so cold starts
|
||||
// don't pay ~700 sync getaddrinfo calls. Restored entries fall through to the
|
||||
// stale-while-revalidate path on first lookup.
|
||||
val dnsStore = AmethystDnsStore(appContext)
|
||||
|
||||
// manages all the other connections separately from relays.
|
||||
val okHttpClients =
|
||||
DualHttpClientManager(
|
||||
@@ -501,6 +507,22 @@ class AppModules(
|
||||
fun initiate(appContext: Context) {
|
||||
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
|
||||
|
||||
// Restore the persisted DNS cache before any networking starts. Lookups that fire
|
||||
// before this completes fall through to the sync resolver path (existing behavior);
|
||||
// once restored, every previously-seen host hits the stale-while-revalidate path
|
||||
// instead of blocking on getaddrinfo.
|
||||
applicationIOScope.launch {
|
||||
dnsStore.load()
|
||||
}
|
||||
|
||||
// Periodically flush the DNS cache. Saves are skipped when nothing has changed.
|
||||
applicationIOScope.launch {
|
||||
while (true) {
|
||||
delay(5 * 60 * 1000L)
|
||||
dnsStore.save()
|
||||
}
|
||||
}
|
||||
|
||||
applicationIOScope.launch {
|
||||
// loads main account quickly.
|
||||
LocalPreferences.loadAccountConfigFromEncryptedStorage()
|
||||
@@ -565,12 +587,17 @@ class AppModules(
|
||||
BackgroundMedia.removeBackgroundControllerAndReleaseIt()
|
||||
PlaybackServiceClient.shutdown()
|
||||
alwaysOnNotificationServiceManager.stop()
|
||||
// Best-effort flush before the scope is cancelled. Android rarely calls onTerminate in
|
||||
// production, but when it does we get one last chance to persist the cache.
|
||||
runCatching { dnsStore.save() }
|
||||
applicationIOScope.cancel("Application onTerminate $appContext")
|
||||
accountsCache.clear()
|
||||
}
|
||||
|
||||
fun trim() {
|
||||
applicationIOScope.launch {
|
||||
// Backgrounding is a natural moment to flush the DNS cache.
|
||||
dnsStore.save()
|
||||
val loggedIn = accountsCache.accounts.value.values
|
||||
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Concurrent, caching, stale-while-revalidate DNS resolver for OkHttp.
|
||||
@@ -57,9 +58,11 @@ import java.util.concurrent.TimeUnit
|
||||
* 5. **Generous positive TTL with jitter.** Defaults to 24h plus up to 24h of random jitter,
|
||||
* so each entry expires somewhere in [base, base + jitter]. This breaks the synchronized
|
||||
* herd that would otherwise form when ~700 relay reconnects all populate the cache in the
|
||||
* same second: their expiries spread across a 24h window instead of landing at the same
|
||||
* instant 24h later. A user who opens the app once a day catches only a small fraction of
|
||||
* entries stale per session, and refreshes drip through the executor pool naturally.
|
||||
* same second.
|
||||
* 6. **Persistable across restarts.** [snapshot] and [restore] expose the positive cache as a
|
||||
* plain data list so a companion store can survive process death. Wall-clock millis are
|
||||
* used for expiries (not [System.nanoTime], which resets per process) so a restored entry
|
||||
* keeps its remaining lifetime.
|
||||
*
|
||||
* Remaining blocking points: the very first lookup of a host blocks on `getaddrinfo`
|
||||
* (unavoidable — there's nothing to serve stale yet), and followers waiting on that first
|
||||
@@ -73,17 +76,18 @@ class AmethystDns(
|
||||
negativeTtlMs: Long = TimeUnit.SECONDS.toMillis(10),
|
||||
private val refreshExecutor: Executor = DEFAULT_REFRESH_EXECUTOR,
|
||||
) : Dns {
|
||||
private val positiveTtlNanos = TimeUnit.MILLISECONDS.toNanos(positiveTtlMs)
|
||||
private val positiveTtlJitterNanos = TimeUnit.MILLISECONDS.toNanos(positiveTtlJitterMs)
|
||||
private val negativeTtlNanos = TimeUnit.MILLISECONDS.toNanos(negativeTtlMs)
|
||||
private val positiveTtlMillis = positiveTtlMs
|
||||
private val positiveTtlJitterMillis = positiveTtlJitterMs
|
||||
private val negativeTtlMillis = negativeTtlMs
|
||||
|
||||
private val cache = ConcurrentHashMap<String, Entry>()
|
||||
private val inflight = ConcurrentHashMap<String, CompletableFuture<List<InetAddress>>>()
|
||||
private val dirty = AtomicBoolean(false)
|
||||
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
cache[hostname]?.let { entry ->
|
||||
val now = System.nanoTime()
|
||||
if (entry.expiresAtNanos > now) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (entry.expiresAtMillis > now) {
|
||||
return entry.unwrap(hostname)
|
||||
}
|
||||
// Soft-expired positive entry: serve stale, refresh in background. Negative
|
||||
@@ -130,7 +134,7 @@ class AmethystDns(
|
||||
// our miss and our putIfAbsent. Skips a duplicate getaddrinfo in that race.
|
||||
val cached = cache[hostname]
|
||||
val addresses =
|
||||
if (cached != null && cached.expiresAtNanos > System.nanoTime()) {
|
||||
if (cached != null && cached.expiresAtMillis > System.currentTimeMillis()) {
|
||||
cached.unwrap(hostname)
|
||||
} else {
|
||||
try {
|
||||
@@ -176,41 +180,91 @@ class AmethystDns(
|
||||
// Jitter the expiry so a burst of co-written entries (e.g. ~700 relay reconnects at app
|
||||
// start) doesn't all expire at the same instant 24h later.
|
||||
val jitter =
|
||||
if (positiveTtlJitterNanos > 0L) {
|
||||
ThreadLocalRandom.current().nextLong(positiveTtlJitterNanos)
|
||||
if (positiveTtlJitterMillis > 0L) {
|
||||
ThreadLocalRandom.current().nextLong(positiveTtlJitterMillis)
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
cache[hostname] = Entry(addresses, System.nanoTime() + positiveTtlNanos + jitter)
|
||||
cache[hostname] = Entry(addresses, System.currentTimeMillis() + positiveTtlMillis + jitter)
|
||||
dirty.set(true)
|
||||
if (cache.size > maxEntries) evictExpired()
|
||||
}
|
||||
|
||||
private fun putNegative(hostname: String) {
|
||||
cache[hostname] = Entry(emptyList(), System.nanoTime() + negativeTtlNanos)
|
||||
cache[hostname] = Entry(emptyList(), System.currentTimeMillis() + negativeTtlMillis)
|
||||
// Negative entries are never persisted, so they don't dirty the cache.
|
||||
if (cache.size > maxEntries) evictExpired()
|
||||
}
|
||||
|
||||
private fun evictExpired() {
|
||||
val now = System.nanoTime()
|
||||
val now = System.currentTimeMillis()
|
||||
val it = cache.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
if (it.next().value.expiresAtNanos <= now) it.remove()
|
||||
if (it.next().value.expiresAtMillis <= now) it.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop all cached entries. Call when the network changes (e.g. WiFi <-> mobile). */
|
||||
fun invalidate() {
|
||||
cache.clear()
|
||||
dirty.set(true)
|
||||
}
|
||||
|
||||
/** Drop a single host's cached entry. Call when a connection to it fails. */
|
||||
fun invalidate(hostname: String) {
|
||||
cache.remove(hostname)
|
||||
if (cache.remove(hostname) != null) dirty.set(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the positive cache for persistence. Negative entries and expired entries are
|
||||
* omitted. The expiry timestamps are wall-clock millis (epoch), so they remain meaningful
|
||||
* across process restarts.
|
||||
*/
|
||||
fun snapshot(): List<DnsCacheRecord> {
|
||||
val now = System.currentTimeMillis()
|
||||
val out = ArrayList<DnsCacheRecord>(cache.size)
|
||||
for ((host, entry) in cache) {
|
||||
if (entry.addresses.isEmpty()) continue
|
||||
if (entry.expiresAtMillis <= now) continue
|
||||
val ips = entry.addresses.mapNotNull { it.hostAddress }
|
||||
if (ips.isNotEmpty()) {
|
||||
out += DnsCacheRecord(host, ips, entry.expiresAtMillis)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a previously taken snapshot. Entries already expired on disk are dropped.
|
||||
* Existing in-memory entries are NOT overwritten (so a fresh lookup that completes before
|
||||
* restore lands keeps its newer answer).
|
||||
*/
|
||||
fun restore(records: List<DnsCacheRecord>) {
|
||||
val now = System.currentTimeMillis()
|
||||
for (record in records) {
|
||||
if (record.expiresAtMillis <= now) continue
|
||||
val addresses =
|
||||
record.addresses.mapNotNull { literal ->
|
||||
// getByName on a numeric literal parses without doing DNS.
|
||||
runCatching { InetAddress.getByName(literal) }.getOrNull()
|
||||
}
|
||||
if (addresses.isNotEmpty()) {
|
||||
cache.putIfAbsent(record.hostname, Entry(addresses, record.expiresAtMillis))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the cache has changed since the last [clearDirty]. */
|
||||
fun isDirty(): Boolean = dirty.get()
|
||||
|
||||
/** Marks the cache clean. Call after a successful persist. */
|
||||
fun clearDirty() {
|
||||
dirty.set(false)
|
||||
}
|
||||
|
||||
private class Entry(
|
||||
val addresses: List<InetAddress>,
|
||||
val expiresAtNanos: Long,
|
||||
val expiresAtMillis: Long,
|
||||
) {
|
||||
fun unwrap(hostname: String): List<InetAddress> = addresses.ifEmpty { throw UnknownHostException(hostname) }
|
||||
}
|
||||
@@ -232,3 +286,13 @@ class AmethystDns(
|
||||
val shared: AmethystDns by lazy { AmethystDns() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Persistable record. Public so [AmethystDnsStore] can serialize it via Jackson. */
|
||||
data class DnsCacheRecord(
|
||||
val hostname: String,
|
||||
val addresses: List<String>,
|
||||
val expiresAtMillis: Long,
|
||||
) {
|
||||
// No-arg constructor for Jackson.
|
||||
constructor() : this("", emptyList(), 0L)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 android.content.Context
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Persists [AmethystDns]'s positive cache to a small `SharedPreferences` blob so the resolver
|
||||
* starts hot after a process restart.
|
||||
*
|
||||
* Cold starts are the resolver's worst case — every host is a sync `getaddrinfo`. With a
|
||||
* persisted snapshot, every previously-seen host falls into the stale-while-revalidate path on
|
||||
* first lookup: the cached IP is served immediately, and a background refresh updates it. That
|
||||
* turns ~700 blocking system calls at app start into zero.
|
||||
*
|
||||
* The blob is plain (not encrypted) — hostnames are already exposed in the user's signed relay
|
||||
* list, Coil's image cache, and the system resolver's own state. ~700 entries × ~80 bytes ≈
|
||||
* ~55 KB of JSON.
|
||||
*/
|
||||
class AmethystDnsStore(
|
||||
private val context: Context,
|
||||
private val dns: AmethystDns = AmethystDns.shared,
|
||||
) {
|
||||
private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
|
||||
|
||||
/**
|
||||
* Read the persisted snapshot and merge it into the resolver. Existing in-memory entries
|
||||
* are preserved (see [AmethystDns.restore]). Safe to call once at app start. Blocking I/O —
|
||||
* call from a background thread.
|
||||
*/
|
||||
fun load() {
|
||||
val json = prefs.getString(KEY_CACHE, null) ?: return
|
||||
val records =
|
||||
try {
|
||||
MAPPER.readValue<List<DnsCacheRecord>>(json)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" }
|
||||
prefs.edit().remove(KEY_CACHE).apply()
|
||||
return
|
||||
}
|
||||
dns.restore(records)
|
||||
// Restoring entries that already existed in memory is a no-op, but the act of loading
|
||||
// shouldn't mark the cache dirty.
|
||||
dns.clearDirty()
|
||||
Log.d(TAG) { "Restored ${records.size} DNS cache entries" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the current snapshot to disk if the cache has changed since the last save. Blocking
|
||||
* I/O — call from a background thread.
|
||||
*/
|
||||
fun save() {
|
||||
if (!dns.isDirty()) return
|
||||
val records = dns.snapshot()
|
||||
try {
|
||||
val json = MAPPER.writeValueAsString(records)
|
||||
prefs.edit().putString(KEY_CACHE, json).apply()
|
||||
dns.clearDirty()
|
||||
Log.d(TAG) { "Persisted ${records.size} DNS cache entries" }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG) { "Failed to persist DNS cache: ${t.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */
|
||||
fun clear() {
|
||||
prefs.edit().remove(KEY_CACHE).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AmethystDnsStore"
|
||||
private const val PREFS_NAME = "amethyst_dns_cache"
|
||||
private const val KEY_CACHE = "dns_cache_v1"
|
||||
private val MAPPER = jacksonObjectMapper()
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import okhttp3.Dns
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -316,4 +317,97 @@ class AmethystDnsTest {
|
||||
assertEquals(2, upstream.calls("a.example"))
|
||||
assertEquals(1, upstream.calls("b.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot includes only fresh positive entries`() {
|
||||
val upstream =
|
||||
CountingDns(
|
||||
mapOf(
|
||||
"live.example" to listOf(ip("1.2.3.4")),
|
||||
"missing.example" to emptyList(),
|
||||
),
|
||||
)
|
||||
val dns =
|
||||
AmethystDns(
|
||||
delegate = upstream,
|
||||
positiveTtlMs = 60_000,
|
||||
positiveTtlJitterMs = 0,
|
||||
)
|
||||
dns.lookup("live.example")
|
||||
runCatching { dns.lookup("missing.example") }
|
||||
|
||||
val snapshot = dns.snapshot()
|
||||
assertEquals(1, snapshot.size)
|
||||
assertEquals("live.example", snapshot[0].hostname)
|
||||
assertEquals(listOf("1.2.3.4"), snapshot[0].addresses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore replays cached entries without hitting upstream`() {
|
||||
val upstream = CountingDns(mapOf("relay.example" to listOf(ip("9.9.9.9"))))
|
||||
val dns = AmethystDns(delegate = upstream)
|
||||
|
||||
val expiresAt = System.currentTimeMillis() + 60_000
|
||||
dns.restore(listOf(DnsCacheRecord("relay.example", listOf("9.9.9.9"), expiresAt)))
|
||||
|
||||
assertEquals(listOf(ip("9.9.9.9")), dns.lookup("relay.example"))
|
||||
assertEquals("Restored entry should serve without upstream", 0, upstream.calls("relay.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore drops entries already expired on disk`() {
|
||||
val upstream = CountingDns(mapOf("relay.example" to listOf(ip("9.9.9.9"))))
|
||||
val dns = AmethystDns(delegate = upstream)
|
||||
|
||||
val expiredAt = System.currentTimeMillis() - 1_000
|
||||
dns.restore(listOf(DnsCacheRecord("relay.example", listOf("9.9.9.9"), expiredAt)))
|
||||
|
||||
dns.lookup("relay.example")
|
||||
assertEquals(1, upstream.calls("relay.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore does not overwrite a fresh in-memory entry`() {
|
||||
val upstream = CountingDns(mapOf("relay.example" to listOf(ip("1.1.1.1"))))
|
||||
val dns =
|
||||
AmethystDns(
|
||||
delegate = upstream,
|
||||
positiveTtlMs = 60_000,
|
||||
positiveTtlJitterMs = 0,
|
||||
)
|
||||
|
||||
// Live lookup populates with 1.1.1.1.
|
||||
dns.lookup("relay.example")
|
||||
// Then a (stale-on-disk-but-not-yet-expired) restore arrives with a different IP.
|
||||
dns.restore(
|
||||
listOf(
|
||||
DnsCacheRecord(
|
||||
"relay.example",
|
||||
listOf("9.9.9.9"),
|
||||
System.currentTimeMillis() + 60_000,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("In-memory entry wins", listOf(ip("1.1.1.1")), dns.lookup("relay.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dirty flag tracks positive writes`() {
|
||||
val upstream = CountingDns(mapOf("a.example" to listOf(ip("1.2.3.4"))))
|
||||
val dns =
|
||||
AmethystDns(
|
||||
delegate = upstream,
|
||||
positiveTtlMs = 60_000,
|
||||
positiveTtlJitterMs = 0,
|
||||
)
|
||||
|
||||
assertFalse("Fresh resolver is not dirty", dns.isDirty())
|
||||
dns.lookup("a.example")
|
||||
assertTrue("First positive write dirties cache", dns.isDirty())
|
||||
|
||||
dns.clearDirty()
|
||||
dns.lookup("a.example") // cache hit, no write
|
||||
assertFalse("Cache hit does not dirty", dns.isDirty())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user