perf: drain Coil disk cache off the write path to prevent scroll stalls

When Coil's DiskLruCache is saturated, every commit() triggers an inline
trimToSize() that performs file deletes synchronously on the IO dispatcher.
With Nostr feed scroll loading many large source avatars, each new write
evicts dozens of small entries, contending with concurrent reads of the
on-screen images and producing visible avatar-load latency and scroll jank.

Take ownership of eviction so size stays < maxSize during normal use:

- TrackingDiskCache wraps the real Coil DiskCache and records every key
  access (openEditor/openSnapshot) into a persistent log.
- KeyAccessLog persists (timestamp, key) pairs in an append-only file under
  filesDir, replayed on startup. Bounded at 200K entries with periodic
  compaction.
- CoilDiskTrimmer runs on a 5-minute cadence and on onTrimMemory: when size
  crosses 70% of maxSize it removes oldest keys via the public remove(key)
  API until size drops to 55%. Coil's internal trim remains as a backstop
  for keys we don't yet know about.

Unit tests cover persistence, idempotent load, corrupt-line recovery,
overflow handling, target/floor draining, oldest-first ordering, empty-log
fallback, and concurrent-call serialisation.
This commit is contained in:
Claude
2026-05-14 04:26:20 +00:00
parent f3574081b5
commit b221dce87f
6 changed files with 937 additions and 2 deletions
@@ -47,9 +47,12 @@ import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.CoilDiskTrimmer
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.images.KeyAccessLog
import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
import com.vitorpamplona.amethyst.service.images.TrackingDiskCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
@@ -117,6 +120,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
@@ -507,10 +511,33 @@ class AppModules(
VideoCacheFactory.new(appContext)
}
// image cache in disk for coil
// shadow index of Coil's disk cache keys so the trimmer can evict
// oldest-first via Coil's public remove(key) API.
val coilKeyAccessLog: KeyAccessLog by lazy {
KeyAccessLog(
logFile = File(appContext.filesDir, "coil_key_access.log"),
scope = applicationIOScope,
)
}
// image cache in disk for coil. Wrapped in TrackingDiskCache so the
// background trimmer can keep `size < maxSize` and prevent inline
// eviction storms on the write path during feed scroll.
val diskCache: DiskCache by lazy {
Log.d("AppModules", "ImageCacheFactory Init")
ImageCacheFactory.newDisk(appContext)
TrackingDiskCache(
delegate = ImageCacheFactory.newDisk(appContext),
keyLog = coilKeyAccessLog,
)
}
// Background trimmer that drains the Coil disk cache to ~55% of cap
// whenever it crosses ~70%. Runs on a fixed cadence and on onTrimMemory.
val coilDiskTrimmer: CoilDiskTrimmer by lazy {
CoilDiskTrimmer(
diskCache = { diskCache },
keyLog = coilKeyAccessLog,
)
}
// image cache in memory for coil
@@ -663,6 +690,19 @@ class AppModules(
delay(1_500)
videoCache
}
// Replay the persisted Coil key-access log into memory so the
// background trimmer can evict oldest-first via remove(key). Then
// run the trim loop while the app is alive. The first pass is
// delayed so it doesn't compete with first-paint IO.
applicationIOScope.launch {
coilKeyAccessLog.load()
delay(30_000)
while (isActive) {
coilDiskTrimmer.trim()
delay(5 * 60 * 1000L)
}
}
}
fun terminate(appContext: Context) {
@@ -684,6 +724,10 @@ class AppModules(
dnsStore.save()
val loggedIn = accountsCache.accounts.value.values
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
// Drain the Coil disk cache below target on backgrounding too —
// doing it here keeps the next foreground session's write path on
// the no-inline-eviction fast path.
coilDiskTrimmer.trim()
}
}
}
@@ -0,0 +1,111 @@
/*
* 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.images
import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.sync.Mutex
/**
* Drains Coil's disk cache below [targetFraction] of `maxSize`, by calling
* `DiskCache.remove(key)` for the oldest keys recorded in [keyLog]. Called
* from a background coroutine on a fixed cadence (e.g. every 5 minutes) and
* on `onTrimMemory`.
*
* The point is to keep `size < maxSize` so Coil's internal `trimToSize()`
* never fires during `commit()`. Inline eviction is what stalls IO on a
* saturated cache; moving it out of the write path is the whole reason this
* class exists.
*
* On every run:
* 1. Flush [keyLog]'s pending writes so the snapshot is current.
* 2. If `size < targetFraction * maxSize`, exit (nothing to do).
* 3. Walk the log oldest-first, calling `remove(key)` until
* `size < floorFraction * maxSize`. The hysteresis gap between target and
* floor gives bursty writes some headroom before the next run is needed.
*
* Keys that the trimmer doesn't know about (entries written before this code
* was deployed, or after the in-memory map filled to capacity) stay on disk
* until Coil's own internal trim notices them — that's the safety net.
*/
@OptIn(ExperimentalCoilApi::class)
class CoilDiskTrimmer(
private val diskCache: () -> DiskCache,
private val keyLog: KeyAccessLog,
private val targetFraction: Double = 0.70,
private val floorFraction: Double = 0.55,
) {
companion object {
private const val TAG = "CoilDiskTrimmer"
}
private val runMutex = Mutex()
init {
require(floorFraction < targetFraction) { "floor must be < target" }
require(targetFraction in 0.0..1.0) { "target out of range" }
require(floorFraction in 0.0..1.0) { "floor out of range" }
}
/**
* Run one trim pass. No-op if another pass is already running.
*/
suspend fun trim() {
if (!runMutex.tryLock()) return
try {
keyLog.flushNow()
val cache = diskCache()
val maxSize = cache.maxSize
if (maxSize <= 0L) return
val target = (maxSize * targetFraction).toLong()
val floor = (maxSize * floorFraction).toLong()
val before = cache.size
if (before < target) {
Log.d(TAG) { "size=${before / KB} KB < target=${target / KB} KB; nothing to do" }
return
}
val candidates = keyLog.snapshotOldestFirst()
if (candidates.isEmpty()) {
Log.d(TAG) { "size=${before / KB} KB ≥ target=${target / KB} KB but no known keys; falling back to Coil's inline trim" }
return
}
val started = System.currentTimeMillis()
var evicted = 0
for (entry in candidates) {
if (cache.size <= floor) break
if (cache.remove(entry.key)) evicted++
}
val after = cache.size
val elapsed = System.currentTimeMillis() - started
Log.d(TAG) {
"trimmed ${(before - after) / KB} KB in ${elapsed}ms " +
"(${before / KB}${after / KB} KB, target=${target / KB} KB, evicted=$evicted)"
}
} finally {
runMutex.unlock()
}
}
private val KB get() = 1024L
}
@@ -0,0 +1,262 @@
/*
* 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.images
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
* Persistent shadow index of Coil's disk cache: maps each Coil key to its
* last-known access timestamp.
*
* Coil 3's `DiskCache` interface does not expose iteration, so we cannot ask
* Coil "which keys exist, in LRU order." This class observes every
* `openEditor` / `openSnapshot` call via [TrackingDiskCache] and writes the
* pair `(timestamp, key)` to an append-only log. On startup the log is
* replayed into [accessTimes] so that the trimmer
* ([CoilDiskTrimmer]) can choose oldest-first eviction candidates without
* touching Coil internals.
*
* Format:
* ```
* <epochMs>\t<key>\n
* <epochMs>\t<key>\n
* ...
* ```
* Multiple lines for the same key are tolerated; the latest wins on replay.
*
* Capacity: bounded at [maxEntries]. When exceeded, the oldest entries are
* dropped from the in-memory map. Those keys become invisible to the trimmer
* (the trimmer will not call `remove` on them) but Coil's own internal trim
* remains as a backstop.
*
* Compaction: when the on-disk log grows past [compactionRatio] × in-memory
* size, the log is rewritten as a fresh snapshot.
*/
class KeyAccessLog(
private val logFile: File,
private val scope: CoroutineScope,
private val maxEntries: Int = 200_000,
private val flushIntervalMs: Long = 30_000L,
private val compactionRatio: Int = 3,
) {
companion object {
private const val TAG = "KeyAccessLog"
}
private val accessTimes = ConcurrentHashMap<String, Long>()
private val pendingWrites = ConcurrentHashMap<String, Long>()
private val ioMutex = Mutex()
private val bytesWritten = AtomicLong(0L)
private val loaded = AtomicLong(0L)
@Volatile private var flushJob: Job? = null
/** Reload the in-memory map from the log file. Idempotent. */
suspend fun load() {
if (!loaded.compareAndSet(0L, 1L)) return
withContext(Dispatchers.IO) {
ioMutex.withLock {
if (!logFile.exists()) {
bytesWritten.set(0L)
return@withLock
}
var size = 0L
try {
logFile.bufferedReader().useLines { lines ->
for (line in lines) {
size += line.length + 1
val tab = line.indexOf('\t')
if (tab <= 0 || tab >= line.length - 1) continue
val ts = line.substring(0, tab).toLongOrNull() ?: continue
val key = line.substring(tab + 1)
val prev = accessTimes[key]
if (prev == null || ts > prev) accessTimes[key] = ts
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed reading log; resetting", e)
runCatching { logFile.delete() }
accessTimes.clear()
bytesWritten.set(0L)
return@withLock
}
bytesWritten.set(size)
if (accessTimes.size > maxEntries) {
dropOldestLocked(accessTimes.size - maxEntries)
rewriteLocked()
}
Log.d(TAG) { "Loaded ${accessTimes.size} entries (${size}B on disk)" }
}
}
}
/** Record that [key] was accessed at [timeMs] (defaults to now). */
fun recordAccess(
key: String,
timeMs: Long = System.currentTimeMillis(),
) {
val prev = accessTimes.put(key, timeMs)
if (prev == null || timeMs - prev >= flushIntervalMs / 2) {
pendingWrites[key] = timeMs
scheduleFlush()
}
}
/** Forget a key. Caller is the eviction path (after `remove`). */
fun forget(key: String) {
accessTimes.remove(key)
pendingWrites.remove(key)
}
/** Forget every key. Caller is `DiskCache.clear()`. */
fun forgetAll() {
accessTimes.clear()
pendingWrites.clear()
scope.launch(Dispatchers.IO) {
ioMutex.withLock {
runCatching { logFile.delete() }
bytesWritten.set(0L)
}
}
}
/** Snapshot of all known (key, lastAccess) pairs, oldest first. */
fun snapshotOldestFirst(): List<Entry> =
accessTimes
.entries
.map { Entry(it.key, it.value) }
.sortedBy { it.lastAccessMs }
fun size(): Int = accessTimes.size
private fun scheduleFlush() {
if (flushJob?.isActive == true) return
synchronized(this) {
if (flushJob?.isActive == true) return
flushJob =
scope.launch(Dispatchers.IO) {
kotlinx.coroutines.delay(flushIntervalMs)
if (scope.isActive) flushNow()
}
}
}
/** Flush pending writes immediately. Visible for tests. */
suspend fun flushNow() {
val toWrite =
ioMutex.withLock {
if (pendingWrites.isEmpty()) return@flushNow
val drained = pendingWrites.toMap()
pendingWrites.clear()
drained
}
appendBatch(toWrite)
maybeCompact()
}
private suspend fun appendBatch(batch: Map<String, Long>) {
if (batch.isEmpty()) return
ioMutex.withLock {
try {
logFile.parentFile?.mkdirs()
var addedBytes = 0L
BufferedWriter(FileWriter(logFile, true)).use { writer ->
for ((key, ts) in batch) {
if ('\n' in key || '\t' in key) continue
val line = "$ts\t$key\n"
writer.write(line)
addedBytes += line.length.toLong()
}
}
bytesWritten.addAndGet(addedBytes)
} catch (e: Exception) {
Log.w(TAG, "appendBatch failed", e)
}
}
}
private suspend fun maybeCompact() {
val mapSize = accessTimes.size
if (mapSize == 0) return
val avgLine = 80L
val expectedBytes = mapSize * avgLine
if (bytesWritten.get() < expectedBytes * compactionRatio) return
ioMutex.withLock {
if (accessTimes.size > maxEntries) {
dropOldestLocked(accessTimes.size - maxEntries)
}
rewriteLocked()
}
}
private fun dropOldestLocked(count: Int) {
if (count <= 0) return
val victims =
accessTimes.entries
.asSequence()
.sortedBy { it.value }
.take(count)
.map { it.key }
.toList()
for (k in victims) accessTimes.remove(k)
}
private fun rewriteLocked() {
try {
logFile.parentFile?.mkdirs()
val tmp = File(logFile.parentFile, "${logFile.name}.tmp")
var bytes = 0L
BufferedWriter(FileWriter(tmp, false)).use { writer ->
for ((key, ts) in accessTimes) {
if ('\n' in key || '\t' in key) continue
val line = "$ts\t$key\n"
writer.write(line)
bytes += line.length.toLong()
}
}
if (!tmp.renameTo(logFile)) {
tmp.copyTo(logFile, overwrite = true)
tmp.delete()
}
bytesWritten.set(bytes)
} catch (e: Exception) {
Log.w(TAG, "rewrite failed", e)
}
}
data class Entry(
val key: String,
val lastAccessMs: Long,
)
}
@@ -0,0 +1,68 @@
/*
* 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.images
import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
/**
* `DiskCache` wrapper that records every key access into [keyLog].
*
* Coil's built-in `DiskLruCache` evicts inline on every `commit()` once
* `size` exceeds `maxSize`. That inline eviction is what stalls feed-scroll
* IO on a saturated cache. By recording access timestamps here we give
* [CoilDiskTrimmer] enough information to run an external eviction pass on a
* background coroutine which keeps `size < maxSize` and turns the inline
* trim into a no-op during normal use.
*
* Delegates every method to the real Coil cache via Kotlin's interface
* delegation. Recording happens before delegation so a key seen via `openEditor`
* always shows up in [keyLog] regardless of whether the editor is later
* aborted that's fine: the trimmer is robust against `remove(key)` of an
* already-evicted key.
*/
@OptIn(ExperimentalCoilApi::class)
class TrackingDiskCache(
private val delegate: DiskCache,
private val keyLog: KeyAccessLog,
) : DiskCache by delegate {
override fun openEditor(key: String): DiskCache.Editor? {
keyLog.recordAccess(key)
return delegate.openEditor(key)
}
override fun openSnapshot(key: String): DiskCache.Snapshot? {
val snap = delegate.openSnapshot(key)
if (snap != null) keyLog.recordAccess(key)
return snap
}
override fun remove(key: String): Boolean {
val removed = delegate.remove(key)
if (removed) keyLog.forget(key)
return removed
}
override fun clear() {
delegate.clear()
keyLog.forgetAll()
}
}
@@ -0,0 +1,216 @@
/*
* 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.images
import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicLong
@OptIn(ExperimentalCoilApi::class)
class CoilDiskTrimmerTest {
private lateinit var tmpDir: File
private lateinit var logFile: File
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Before
fun setUp() {
tmpDir = Files.createTempDirectory("coil-trimmer-test").toFile()
logFile = File(tmpDir, "log.tsv")
}
@After
fun tearDown() {
scope.cancel("tearDown")
tmpDir.deleteRecursively()
}
/**
* Tiny fake of [DiskCache] that only models `size`, `maxSize`, and
* `remove`. Each call to `remove(key)` decrements `size` by the per-entry
* byte budget we hand in. Other methods of `DiskCache` are not relevant
* to the trimmer and are stubbed via mockk so any accidental call would
* blow up loud in tests.
*/
private class FakeDiskCache(
override val maxSize: Long,
private val bytesPerEntry: Long,
) : DiskCache by mockk(relaxed = false) {
private val current = AtomicLong(0L)
override val size: Long get() = current.get()
fun setSize(bytes: Long) {
current.set(bytes)
}
override fun remove(key: String): Boolean {
current.addAndGet(-bytesPerEntry)
return true
}
}
@Test
fun trim_belowTarget_isNoop() =
runTest {
val cache = FakeDiskCache(maxSize = 1000L, bytesPerEntry = 10L)
cache.setSize(500L) // 50% — below 70% target
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
for (i in 1..50) log.recordAccess("k$i", timeMs = i.toLong())
log.flushNow()
val trimmer = CoilDiskTrimmer({ cache }, log)
trimmer.trim()
assertEquals(500L, cache.size)
}
@Test
fun trim_aboveTarget_drainsToFloor() =
runTest {
val cache = FakeDiskCache(maxSize = 1000L, bytesPerEntry = 10L)
cache.setSize(900L) // 90%, well above 70% target
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
// 90 known entries so we definitely have enough to evict.
for (i in 1..90) log.recordAccess("k$i", timeMs = i.toLong())
log.flushNow()
val trimmer = CoilDiskTrimmer({ cache }, log)
trimmer.trim()
// Floor is 55% = 550 bytes. Trimmer stops at the first call where
// size has dipped at or below the floor — with 10-byte entries we
// stop at exactly 550 (35 evictions: 900 - 35*10 = 550).
assertTrue("size=${cache.size} should be at/below floor 550", cache.size <= 550L)
assertTrue("size=${cache.size} should not over-evict past floor", cache.size >= 540L)
}
@Test
fun trim_evictsOldestFirst() =
runTest {
val cache = FakeDiskCache(maxSize = 1000L, bytesPerEntry = 10L)
cache.setSize(800L) // 80%, above target
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("oldest", timeMs = 100)
log.recordAccess("middle", timeMs = 200)
log.recordAccess("newest", timeMs = 300)
// Pad the log so we have plenty of keys to choose from.
for (i in 1..30) log.recordAccess("pad$i", timeMs = 1000L + i)
log.flushNow()
val evicted = mutableListOf<String>()
val tracked =
object : DiskCache by cache {
override fun remove(key: String): Boolean {
evicted += key
return cache.remove(key)
}
}
val trimmer = CoilDiskTrimmer({ tracked }, log)
trimmer.trim()
// First three evictions must be the three oldest in that order.
assertTrue("evicted=$evicted; first 3 must be oldest-first", evicted.size >= 3)
assertEquals("oldest", evicted[0])
assertEquals("middle", evicted[1])
assertEquals("newest", evicted[2])
}
@Test
fun trim_emptyKeyLog_isNoopEvenWhenOverTarget() =
runTest {
val cache = FakeDiskCache(maxSize = 1000L, bytesPerEntry = 10L)
cache.setSize(950L) // 95%, well above target
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load() // no entries
val trimmer = CoilDiskTrimmer({ cache }, log)
trimmer.trim()
// No known keys to evict; relies on Coil's own internal trim.
assertEquals(950L, cache.size)
}
@Test
fun trim_zeroMaxSize_isNoop() =
runTest {
val cache = mockk<DiskCache>(relaxed = true)
every { cache.maxSize } returns 0L
every { cache.size } returns 100L
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("a", timeMs = 100)
log.flushNow()
val trimmer = CoilDiskTrimmer({ cache }, log)
trimmer.trim()
verify(exactly = 0) { cache.remove(any()) }
}
@Test
fun trim_concurrentCalls_serialise() =
runTest {
val cache = FakeDiskCache(maxSize = 1000L, bytesPerEntry = 10L)
cache.setSize(900L)
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
for (i in 1..100) log.recordAccess("k$i", timeMs = i.toLong())
log.flushNow()
val trimmer = CoilDiskTrimmer({ cache }, log)
// Two concurrent trim() calls: the second should tryLock-fail and
// bail out; the first should still drain to floor.
coroutineScope {
listOf(async { trimmer.trim() }, async { trimmer.trim() }).awaitAll()
}
assertTrue(cache.size <= 550L)
}
}
@@ -0,0 +1,234 @@
/*
* 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.images
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
class KeyAccessLogTest {
private lateinit var tmpDir: File
private lateinit var logFile: File
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Before
fun setUp() {
tmpDir = Files.createTempDirectory("coil-keylog-test").toFile()
logFile = File(tmpDir, "log.tsv")
}
@After
fun tearDown() {
scope.cancel("tearDown")
tmpDir.deleteRecursively()
}
@Test
fun recordAccess_persistsAndReplaysOnLoad() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("a", timeMs = 100)
log.recordAccess("b", timeMs = 200)
log.recordAccess("c", timeMs = 300)
log.flushNow()
val reloaded = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
reloaded.load()
val snapshot = reloaded.snapshotOldestFirst()
assertEquals(3, snapshot.size)
assertEquals(listOf("a", "b", "c"), snapshot.map { it.key })
assertEquals(listOf(100L, 200L, 300L), snapshot.map { it.lastAccessMs })
}
@Test
fun recordAccess_laterTimestampWins() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("a", timeMs = 100)
log.recordAccess("a", timeMs = 500)
log.recordAccess("a", timeMs = 300)
log.flushNow()
val reloaded = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
reloaded.load()
val snapshot = reloaded.snapshotOldestFirst()
assertEquals(1, snapshot.size)
assertEquals("a", snapshot[0].key)
assertEquals(500L, snapshot[0].lastAccessMs)
}
@Test
fun forget_removesKey() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("a", timeMs = 100)
log.recordAccess("b", timeMs = 200)
log.forget("a")
log.flushNow()
assertEquals(listOf("b"), log.snapshotOldestFirst().map { it.key })
}
@Test
fun forgetAll_clearsLogFile() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("a", timeMs = 100)
log.flushNow()
assertTrue(logFile.exists())
log.forgetAll()
// Give the IO-dispatched delete a moment.
kotlinx.coroutines.delay(100)
assertEquals(0, log.snapshotOldestFirst().size)
}
@Test
fun snapshotOldestFirst_sortsByTimestamp() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("newest", timeMs = 500)
log.recordAccess("oldest", timeMs = 100)
log.recordAccess("middle", timeMs = 300)
log.flushNow()
val snapshot = log.snapshotOldestFirst()
assertEquals(listOf("oldest", "middle", "newest"), snapshot.map { it.key })
}
@Test
fun load_isIdempotent() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.recordAccess("a", timeMs = 100)
log.flushNow()
log.load()
log.load() // second call should be a no-op
assertEquals(1, log.snapshotOldestFirst().size)
}
@Test
fun maxEntries_dropsOldestOnOverflow() =
runTest {
val log =
KeyAccessLog(
logFile,
scope,
flushIntervalMs = 1L,
maxEntries = 3,
compactionRatio = 1,
)
log.load()
for (i in 1..10) log.recordAccess("k$i", timeMs = i.toLong() * 100)
log.flushNow()
// Force compaction so overflow trimming runs.
for (i in 11..20) log.recordAccess("k$i", timeMs = i.toLong() * 100)
log.flushNow()
// Reload and verify only the newest survived.
val reloaded = KeyAccessLog(logFile, scope, flushIntervalMs = 1L, maxEntries = 3)
reloaded.load()
val keys = reloaded.snapshotOldestFirst().map { it.key }.toSet()
assertTrue("only newest 3 should survive, got=$keys", keys.size <= 3)
// The newest entries must include k20.
assertTrue("k20 must survive", "k20" in keys)
}
@Test
fun load_recoversFromCorruptedLines() =
runTest {
logFile.parentFile?.mkdirs()
logFile.writeText(
buildString {
appendLine("100\tgood-a")
appendLine("not a number\tbad")
appendLine("\tno-timestamp")
appendLine("200")
appendLine("300\tgood-b")
},
)
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
val keys = log.snapshotOldestFirst().map { it.key }
assertEquals(listOf("good-a", "good-b"), keys)
}
@Test
fun keysWithTabOrNewline_areSkippedOnWrite() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
log.recordAccess("ok", timeMs = 100)
log.recordAccess("has\ttab", timeMs = 200)
log.recordAccess("has\nnewline", timeMs = 300)
log.flushNow()
val reloaded = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
reloaded.load()
// Only the clean key should round-trip.
assertEquals(listOf("ok"), reloaded.snapshotOldestFirst().map { it.key })
}
@Test
fun size_reflectsInMemoryCount() =
runTest {
val log = KeyAccessLog(logFile, scope, flushIntervalMs = 1L)
log.load()
assertEquals(0, log.size())
log.recordAccess("a", timeMs = 100)
log.recordAccess("b", timeMs = 200)
assertEquals(2, log.size())
log.forget("a")
assertEquals(1, log.size())
assertNotNull(log.snapshotOldestFirst().firstOrNull { it.key == "b" })
assertNull(log.snapshotOldestFirst().firstOrNull { it.key == "a" })
}
}