feat(android): the audio cache keeps a Liked songs budget and a Recently played budget (#6307)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped

SimpleCache ran one LRU evictor at the rolling cap, so liked audio was
dropped like anything else and the offline Liked pool shrank as other
music played. TwoBucketCacheEvictor replaces it:

- a span counts against Liked when its track is liked, else Recently
  played; a liked track that is also played is charged to Liked only;
- each budget has its own cap (0 = unlimited) and drops its own
  least-recently-used audio first;
- CacheBudgetSync feeds it the signed-in user's liked ids and the Storage
  caps, so a like, an unlike or a cap change applies at once;
- nothing is evicted until the liked set is known, so startup never trims
  liked audio as Recently played.

The decisions live in a Media3-free BucketLedger with unit tests.
CacheConfig and PlayerModule, which only snapshotted the caps at launch,
are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 15:16:36 -04:00
co-authored by Claude Opus 5.5
parent b61474744b
commit 7ec8a82871
10 changed files with 468 additions and 79 deletions
@@ -8,6 +8,7 @@ import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.audiocache.CacheBudgetSync
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
@@ -120,6 +121,14 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
/**
* Same construct-the-singleton trick — CacheBudgetSync's init block
* feeds the audio cache's evictor the liked set and the Storage caps.
* Without this @Inject the evictor would never arm, and the cache
* would grow without a limit.
*/
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
@@ -0,0 +1,122 @@
package com.fabledsword.minstrel.cache.audiocache
import java.util.TreeSet
/** Which budget a cached track's bytes count against. */
enum class CacheBucket { LIKED, RECENT }
/**
* The bookkeeping behind [TwoBucketCacheEvictor]: which budget each cached
* span counts against, how full each budget is, and which span to drop
* next. It knows nothing of Media3, so it is tested directly.
*
* A span belongs to [CacheBucket.LIKED] when its key (the track id) is in
* the liked set, and to [CacheBucket.RECENT] otherwise. A track that is
* liked and also recently played is therefore charged to Liked only; the
* two budgets never count the same bytes. Each bucket drops its own
* least-recently-used span first, so filling one never evicts the other.
*
* Nothing is evicted until [setLiked] has been called once. Before then
* every span would read as Recently played, and trimming at the Recently
* played cap would throw away liked audio at startup.
*
* Not thread-safe; the evictor serialises every call.
*/
class BucketLedger<S : Any>(
private val keyOf: (S) -> String,
private val lengthOf: (S) -> Long,
lruOrder: Comparator<S>,
) {
private val spans = mapOf(
CacheBucket.LIKED to TreeSet(lruOrder),
CacheBucket.RECENT to TreeSet(lruOrder),
)
private val bytes = mutableMapOf(CacheBucket.LIKED to 0L, CacheBucket.RECENT to 0L)
private val spansByKey = HashMap<String, MutableSet<S>>()
private val bucketByKey = HashMap<String, CacheBucket>()
private var liked: Set<String> = emptySet()
private var armed = false
/** Cap on Liked bytes; 0 means unlimited. */
var likedCapBytes: Long = 0L
/** Cap on Recently played bytes; 0 means unlimited. */
var recentCapBytes: Long = 0L
fun bucketOf(key: String): CacheBucket =
if (key in liked) CacheBucket.LIKED else CacheBucket.RECENT
fun bytesIn(bucket: CacheBucket): Long = bytes.getValue(bucket)
/** Bytes the Liked budget can still take; [Long.MAX_VALUE] when unlimited. */
fun likedRoomBytes(): Long = when {
likedCapBytes <= 0L -> Long.MAX_VALUE
else -> (likedCapBytes - bytesIn(CacheBucket.LIKED)).coerceAtLeast(0L)
}
fun add(span: S) {
val key = keyOf(span)
val bucket = bucketByKey[key] ?: bucketOf(key).also { bucketByKey[key] = it }
if (spans.getValue(bucket).add(span)) {
bytes[bucket] = bytesIn(bucket) + lengthOf(span)
spansByKey.getOrPut(key) { HashSet() }.add(span)
}
}
fun remove(span: S) {
val key = keyOf(span)
val bucket = bucketByKey[key] ?: return
if (spans.getValue(bucket).remove(span)) {
bytes[bucket] = bytesIn(bucket) - lengthOf(span)
}
val forKey = spansByKey[key] ?: return
forKey.remove(span)
if (forKey.isEmpty()) {
spansByKey.remove(key)
bucketByKey.remove(key)
}
}
/**
* Replace the liked set and move every cached track whose bucket
* changed: a newly liked track's bytes leave Recently played for Liked,
* an unliked track's go the other way. Arms eviction.
*/
fun setLiked(ids: Set<String>) {
liked = ids
armed = true
val moves = bucketByKey.filter { (key, bucket) -> bucketOf(key) != bucket }.keys.toList()
for (key in moves) {
val from = bucketByKey.getValue(key)
val to = bucketOf(key)
for (span in spansByKey[key].orEmpty()) {
if (spans.getValue(from).remove(span)) {
bytes[from] = bytesIn(from) - lengthOf(span)
spans.getValue(to).add(span)
bytes[to] = bytesIn(to) + lengthOf(span)
}
}
bucketByKey[key] = to
}
}
/**
* The span to drop next, or null when both budgets fit. [pendingKey] and
* [pendingBytes] describe a write about to start, so room is made for it
* in its own bucket first. Recently played is trimmed before Liked.
*/
fun nextEviction(pendingKey: String? = null, pendingBytes: Long = 0L): S? {
if (!armed) return null
val pendingBucket = pendingKey?.let(::bucketOf)
return listOf(CacheBucket.RECENT, CacheBucket.LIKED).firstNotNullOfOrNull { bucket ->
val incoming = if (bucket == pendingBucket) pendingBytes.coerceAtLeast(0L) else 0L
oldestIfOver(bucket, incoming)
}
}
private fun oldestIfOver(bucket: CacheBucket, incoming: Long): S? {
val cap = if (bucket == CacheBucket.LIKED) likedCapBytes else recentCapBytes
val over = cap > 0L && bytesIn(bucket) + incoming > cap
return if (over) spans.getValue(bucket).firstOrNull() else null
}
}
@@ -0,0 +1,40 @@
package com.fabledsword.minstrel.cache.audiocache
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps [TwoBucketCacheEvictor] in step with the signed-in user's liked
* tracks and the Storage caps. A like moves that track's cached audio into
* the Liked budget, an unlike moves it out, and a cap change trims at once.
*
* The liked set only flows once a user is signed in, and the evictor drops
* nothing until it has one, so liked audio is never trimmed as Recently
* played while the session is still loading.
*
* Eager-constructed via the construct-the-singleton trick in
* `MinstrelApplication`.
*/
@Singleton
class CacheBudgetSync @Inject constructor(
likes: LikesRepository,
authStore: AuthStore,
evictor: TwoBucketCacheEvictor,
@ApplicationScope scope: CoroutineScope,
) {
init {
scope.launch(Dispatchers.IO) {
combine(likes.observeSignedInLikedTrackIds(), authStore.cacheSettings, ::Pair)
.collect { (ids, settings) ->
evictor.update(ids, settings.likedCapBytes, settings.rollingCapBytes)
}
}
}
}
@@ -1,23 +0,0 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the audio cache; the user's Storage settings override them.
*
* - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
* files. Nothing enforces it yet; SimpleCache evicts by the rolling
* cap alone.
* - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
* evicted least-recently-used first.
*
* The values are generous so first-time installs work well without
* configuration.
*/
data class CacheConfig(
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES,
) {
companion object {
const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB
const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB
}
}
@@ -9,12 +9,12 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* User-tunable audio cache settings. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
* unlimited. Nothing enforces it yet: SimpleCache evicts by
* [rollingCapBytes] alone, and the Storage card hides this control.
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* 0 means unlimited. Effective at next app start (SimpleCache is
* built once per process).
* - [likedCapBytes]: the Liked songs budget, for the cached audio of
* liked tracks. 0 means unlimited.
* - [rollingCapBytes]: the Recently played budget, for everything else
* streamed or prefetched. 0 means unlimited.
* A track that is liked and also recently played counts against Liked
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its
@@ -0,0 +1,112 @@
package com.fabledsword.minstrel.cache.audiocache
import androidx.media3.common.C
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheEvictor
import androidx.media3.datasource.cache.CacheSpan
import javax.inject.Inject
import javax.inject.Singleton
/**
* Bytes held against each audio-cache budget, and how much more the Liked
* budget can take ([Long.MAX_VALUE] when it is unlimited).
*/
data class CacheUsage(val likedBytes: Long, val recentBytes: Long, val likedRoomBytes: Long)
/**
* The audio cache's eviction policy: two budgets, Liked songs and Recently
* played, each with its own cap and its own least-recently-used order (see
* [BucketLedger]). SimpleCache takes exactly one evictor, so this one
* carries both budgets.
*
* [CacheBudgetSync] feeds it the signed-in user's liked set and the caps
* from Storage settings, so a like, an unlike or a cap change applies at
* once, without restarting the app.
*
* Locking: SimpleCache calls every [CacheEvictor] method while holding its
* own monitor. Outside calls take the cache's monitor first and the
* ledger's second, the same order as those callbacks, so the two never
* deadlock.
*/
@Singleton
class TwoBucketCacheEvictor @Inject constructor() : CacheEvictor {
private val ledger = BucketLedger<CacheSpan>(
keyOf = { it.key },
lengthOf = { it.length },
lruOrder = compareBy<CacheSpan> { it.lastTouchTimestamp }.then(naturalOrder()),
)
@Volatile private var cache: Cache? = null
/** Called once the SimpleCache exists, so updates can trim it. */
fun attach(cache: Cache) {
this.cache = cache
}
/** Apply a new liked set and caps, then trim whatever no longer fits. */
fun update(likedIds: Set<String>, likedCapBytes: Long, recentCapBytes: Long) {
val c = cache
val apply = {
ledger.likedCapBytes = likedCapBytes
ledger.recentCapBytes = recentCapBytes
ledger.setLiked(likedIds)
}
if (c == null) {
synchronized(ledger) { apply() }
} else {
synchronized(c) {
synchronized(ledger) {
apply()
evict(c, pendingKey = null, pendingBytes = 0L)
}
}
}
}
fun usage(): CacheUsage = synchronized(ledger) {
CacheUsage(
likedBytes = ledger.bytesIn(CacheBucket.LIKED),
recentBytes = ledger.bytesIn(CacheBucket.RECENT),
likedRoomBytes = ledger.likedRoomBytes(),
)
}
override fun requiresCacheSpanTouches(): Boolean = true
override fun onCacheInitialized() = Unit
override fun onStartFile(cache: Cache, key: String, position: Long, length: Long) {
synchronized(ledger) {
evict(cache, key, if (length == C.LENGTH_UNSET.toLong()) 0L else length)
}
}
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
synchronized(ledger) {
ledger.add(span)
evict(cache, pendingKey = null, pendingBytes = 0L)
}
}
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
synchronized(ledger) { ledger.remove(span) }
}
override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) {
synchronized(ledger) {
ledger.remove(oldSpan)
ledger.add(newSpan)
evict(cache, pendingKey = null, pendingBytes = 0L)
}
}
private fun evict(cache: Cache, pendingKey: String?, pendingBytes: Long) {
while (true) {
val span = ledger.nextEviction(pendingKey, pendingBytes) ?: return
cache.removeSpan(span)
// removeSpan reports back through onSpanRemoved; removing here as
// well guarantees the loop advances even if it did not.
ledger.remove(span)
}
}
}
@@ -14,8 +14,12 @@ import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.Retrofit
@@ -117,6 +121,20 @@ class LikesRepository @Inject constructor(
fun observeLikedTrackIds(): Flow<Set<String>> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/**
* The liked track ids of whoever is signed in, following sign-in and
* account changes. Emits nothing while signed out, so a long-lived
* collector never mistakes the anonymous empty set for "no likes".
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
authController.currentUser
.map { it?.id }
.distinctUntilChanged()
.filterNotNull()
.flatMapLatest { userId -> likeDao.observeLikedIdsOfType(userId, ENTITY_TRACK) }
.map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
@@ -10,7 +10,6 @@ import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
@@ -20,7 +19,7 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
@@ -42,8 +41,8 @@ import javax.inject.Singleton
*
* `simpleCache` is exposed as a val for what needs the on-disk bytes:
* the cached-track ids, the offline pools, the prefetcher and the
* Storage card. The `LeastRecentlyUsedCacheEvictor` bounds it at the
* rolling cap; there is no separate liked-track bucket yet.
* Storage card. [TwoBucketCacheEvictor] bounds it by two budgets, Liked
* songs and Recently played.
*
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
@@ -56,7 +55,7 @@ import javax.inject.Singleton
class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig,
private val cacheEvictor: TwoBucketCacheEvictor,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
@@ -68,9 +67,9 @@ class PlayerFactory @Inject constructor(
val simpleCache: SimpleCache = SimpleCache(
cacheDir,
LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes),
cacheEvictor,
StandaloneDatabaseProvider(context),
)
).also(cacheEvictor::attach)
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
// of drop events (rapid SOAP failures during a single tear-down) surfaces
@@ -1,43 +0,0 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't
* need a @Provides here; this module provides the configuration values
* (CacheConfig) it depends on, and any player-related singletons that
* aren't @Inject-constructible.
*
* MinstrelPlayerService injects PlayerFactory and calls `build()` from
* `onCreate`.
*/
@Module
@InstallIn(SingletonComponent::class)
object PlayerModule {
/**
* CacheConfig snapshot from the user's persisted CacheSettings.
* SimpleCache is constructed once per process, so changes via the
* Settings UI take effect on next app launch (not mid-flight).
* AuthStore's StateFlow hydrates async from Room — when the
* factory @Provides runs, the value is either the persisted
* setting (if Room finished first) or CacheSettings.DEFAULT.
* Worst case the user's tuning lands on next-next launch, which
* is acceptable for a single-user device pref.
*/
@Provides
@Singleton
fun provideCacheConfig(authStore: AuthStore): CacheConfig {
val s = authStore.cacheSettings.value
return CacheConfig(
likedCapBytes = s.likedCapBytes,
rollingCapBytes = s.rollingCapBytes,
)
}
}
@@ -0,0 +1,155 @@
package com.fabledsword.minstrel.cache.audiocache
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The two audio-cache budgets: Liked songs and Recently played. A track
* that is liked and also played counts against Liked only, and each budget
* drops its own least-recently-used audio first.
*/
class BucketLedgerTest {
private data class Span(val key: String, val position: Long, val length: Long, val touched: Long)
private fun ledger(likedCap: Long = 0L, recentCap: Long = 0L) = BucketLedger<Span>(
keyOf = { it.key },
lengthOf = { it.length },
lruOrder = compareBy<Span> { it.touched }.thenBy { it.key }.thenBy { it.position },
).apply {
likedCapBytes = likedCap
recentCapBytes = recentCap
}
/** Drop spans the way the evictor does, until both budgets fit. */
private fun BucketLedger<Span>.drain(): List<Span> {
val dropped = mutableListOf<Span>()
while (true) {
val span = nextEviction() ?: return dropped
remove(span)
dropped += span
}
}
@Test
fun `a liked track's bytes count against Liked only`() {
val l = ledger()
l.setLiked(setOf("liked"))
l.add(Span("liked", 0, 300, touched = 1))
l.add(Span("other", 0, 200, touched = 2))
assertEquals(300, l.bytesIn(CacheBucket.LIKED))
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
}
@Test
fun `filling Recently played drops its oldest audio and never liked audio`() {
val l = ledger(likedCap = 1_000, recentCap = 250)
l.setLiked(setOf("liked"))
val liked = Span("liked", 0, 100, touched = 0)
val oldest = Span("a", 0, 100, touched = 1)
l.add(liked)
l.add(oldest)
l.add(Span("b", 0, 100, touched = 2))
l.add(Span("c", 0, 100, touched = 3))
assertEquals(listOf(oldest), l.drain())
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
assertEquals(100, l.bytesIn(CacheBucket.LIKED))
}
@Test
fun `filling Liked drops its least recently played liked audio`() {
val l = ledger(likedCap = 150, recentCap = 1_000)
l.setLiked(setOf("x", "y"))
val older = Span("x", 0, 100, touched = 1)
l.add(older)
l.add(Span("y", 0, 100, touched = 2))
l.add(Span("recent", 0, 500, touched = 0))
assertEquals(listOf(older), l.drain())
assertEquals(500, l.bytesIn(CacheBucket.RECENT))
}
@Test
fun `liking a cached track moves its bytes into Liked`() {
val l = ledger(likedCap = 1_000, recentCap = 150)
l.setLiked(emptySet())
l.add(Span("t", 0, 100, touched = 1))
l.add(Span("t", 100, 50, touched = 2))
l.add(Span("u", 0, 100, touched = 3))
assertEquals(250, l.bytesIn(CacheBucket.RECENT))
l.setLiked(setOf("t"))
assertEquals(150, l.bytesIn(CacheBucket.LIKED))
assertEquals(100, l.bytesIn(CacheBucket.RECENT))
assertEquals(emptyList(), l.drain())
}
@Test
fun `unliking moves the bytes to Recently played, which then trims`() {
val l = ledger(likedCap = 1_000, recentCap = 150)
l.setLiked(setOf("t"))
val unliked = Span("t", 0, 100, touched = 1)
l.add(unliked)
l.add(Span("u", 0, 100, touched = 2))
l.setLiked(emptySet())
assertEquals(listOf(unliked), l.drain())
assertEquals(0, l.bytesIn(CacheBucket.LIKED))
}
@Test
fun `shrinking a cap trims at once`() {
val l = ledger(recentCap = 1_000)
l.setLiked(emptySet())
l.add(Span("a", 0, 100, touched = 1))
l.add(Span("b", 0, 100, touched = 2))
assertEquals(emptyList(), l.drain())
l.recentCapBytes = 100
assertEquals(listOf("a"), l.drain().map { it.key })
}
@Test
fun `a cap of 0 never evicts`() {
val l = ledger(likedCap = 0, recentCap = 0)
l.setLiked(setOf("liked"))
repeat(50) { l.add(Span("k$it", 0, 1_000_000, touched = it.toLong())) }
l.add(Span("liked", 0, 1_000_000, touched = 99))
assertNull(l.nextEviction())
}
@Test
fun `nothing is evicted before the liked set is known`() {
val l = ledger(likedCap = 100, recentCap = 100)
l.add(Span("maybe-liked", 0, 500, touched = 1))
assertNull(l.nextEviction())
}
@Test
fun `a write about to start makes room in its own bucket`() {
val l = ledger(likedCap = 1_000, recentCap = 200)
l.setLiked(setOf("liked"))
l.add(Span("a", 0, 150, touched = 1))
assertNull(l.nextEviction(pendingKey = "liked", pendingBytes = 100))
assertEquals("a", l.nextEviction(pendingKey = "new", pendingBytes = 100)?.key)
}
@Test
fun `Liked room is what the Liked cap has left`() {
val l = ledger(likedCap = 500)
l.setLiked(setOf("x"))
l.add(Span("x", 0, 200, touched = 1))
assertEquals(300, l.likedRoomBytes())
l.likedCapBytes = 0
assertEquals(Long.MAX_VALUE, l.likedRoomBytes())
}
}