feat(android): bound MetadataProvider on-miss fetches to 4 concurrent

This commit is contained in:
2026-05-28 18:26:13 -04:00
parent 5b5bff767a
commit b2512fff4b
2 changed files with 77 additions and 1 deletions
@@ -15,10 +15,14 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
private const val MAX_CONCURRENT_FETCHES = 4
/**
* Cache-first metadata reads with eager on-miss live-fetch.
*
@@ -49,6 +53,12 @@ class MetadataProvider @Inject constructor(
private val inFlightArtists = ConcurrentHashMap<String, Job>()
private val inFlightTracks = ConcurrentHashMap<String, Job>()
// Caps concurrent on-miss fetches so a cold Home (~30 tiles) doesn't
// fire ~30 parallel /api calls. Mirrors Flutter HydrationQueue's
// _maxConcurrent = 4. Per-id dedup (above) is orthogonal: the gate
// bounds distinct-id fetches; extras suspend on the semaphore.
private val fetchGate = Semaphore(MAX_CONCURRENT_FETCHES)
/**
* Live AlbumRef? for [id]. Fires a background fetch the first
* time the cache emits null. Safe to subscribe concurrently from
@@ -98,7 +108,7 @@ class MetadataProvider @Inject constructor(
): Job = inFlight.computeIfAbsent(id) {
appScope.launch {
try {
block()
fetchGate.withPermit { block() }
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
@@ -0,0 +1,66 @@
package com.fabledsword.minstrel.metadata
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.library.data.LibraryRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertTrue
class MetadataProviderTest {
private val albumDao = mockk<CachedAlbumDao>(relaxed = true)
private val artistDao = mockk<CachedArtistDao>(relaxed = true)
private val trackDao = mockk<CachedTrackDao>(relaxed = true)
private val library = mockk<LibraryRepository>(relaxed = true)
@Test
fun `same missing id fetched once (dedup)`() = runTest {
val scope = this
coEvery { albumDao.observeById("a1") } returns flowOf(null)
val provider = MetadataProvider(albumDao, artistDao, trackDao, library, scope)
provider.observeAlbum("a1").first()
provider.observeAlbum("a1").first()
testScheduler.advanceUntilIdle()
coVerify(exactly = 1) { library.refreshAlbumDetail("a1") }
}
@Test
fun `at most four fetches run concurrently`() = runTest {
val scope = this
val live = AtomicInteger(0)
val peak = AtomicInteger(0)
val gate = CompletableDeferred<Unit>()
coEvery { library.refreshAlbumDetail(any()) } coAnswers {
val now = live.incrementAndGet()
peak.updateAndGet { p -> maxOf(p, now) }
gate.await()
live.decrementAndGet()
mockk(relaxed = true)
}
(1..10).forEach { i -> coEvery { albumDao.observeById("a$i") } returns flowOf(null) }
val provider = MetadataProvider(albumDao, artistDao, trackDao, library, scope)
(1..10).forEach { i ->
launch(UnconfinedTestDispatcher(testScheduler)) {
provider.observeAlbum("a$i").first()
}
}
testScheduler.advanceUntilIdle()
assertTrue(peak.get() <= 4, "peak concurrency was ${peak.get()}, expected <= 4")
gate.complete(Unit)
testScheduler.advanceUntilIdle()
}
}