diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index d536908bdd..af11cf8115 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDns.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDns.kt index 22797293ef..e67d4c638b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDns.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDns.kt @@ -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() private val inflight = ConcurrentHashMap>>() + private val dirty = AtomicBoolean(false) override fun lookup(hostname: String): List { 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 { + val now = System.currentTimeMillis() + val out = ArrayList(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) { + 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, - val expiresAtNanos: Long, + val expiresAtMillis: Long, ) { fun unwrap(hostname: String): List = 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, + val expiresAtMillis: Long, +) { + // No-arg constructor for Jackson. + constructor() : this("", emptyList(), 0L) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDnsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDnsStore.kt new file mode 100644 index 0000000000..b9424b2ed3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDnsStore.kt @@ -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>(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() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDnsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDnsTest.kt index 91d25e2572..fcdcbfb733 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDnsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/AmethystDnsTest.kt @@ -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()) + } }