fix: prevent race conditions in thumbnail disk cache
- Atomic writes: write to temp file then rename, so readers never see a partially written JPEG - Deduplication: ConcurrentHashMap tracks in-flight URLs so multiple composables requesting the same profile picture don't trigger duplicate decode+save work https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
This commit is contained in:
+1
-5
@@ -99,11 +99,7 @@ class ProfilePictureFetcher(
|
|||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
// Fire-and-forget: save thumbnail to disk for next load
|
// Fire-and-forget: save thumbnail to disk for next load
|
||||||
backgroundScope.launch {
|
backgroundScope.launch {
|
||||||
try {
|
thumbnailCache.save(originalUrl, bitmap)
|
||||||
thumbnailCache.save(originalUrl, bitmap)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Best-effort
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ImageFetchResult(
|
return ImageFetchResult(
|
||||||
|
|||||||
+38
-8
@@ -25,6 +25,7 @@ import android.graphics.BitmapFactory
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disk cache for pre-resized profile picture thumbnails.
|
* Disk cache for pre-resized profile picture thumbnails.
|
||||||
@@ -33,8 +34,10 @@ import java.io.File
|
|||||||
* This avoids re-decoding large original images from Coil's disk cache on
|
* This avoids re-decoding large original images from Coil's disk cache on
|
||||||
* every recomposition when images are evicted from the memory cache.
|
* every recomposition when images are evicted from the memory cache.
|
||||||
*
|
*
|
||||||
* With 60 profile pictures on screen, reading ~300-600KB of tiny thumbnails
|
* Thread safety:
|
||||||
* is dramatically faster than re-decoding 60 full-size originals.
|
* - Writes use atomic temp-file-then-rename to prevent partial reads.
|
||||||
|
* - [inFlight] tracks URLs currently being saved to prevent duplicate work
|
||||||
|
* when multiple composables request the same profile picture simultaneously.
|
||||||
*/
|
*/
|
||||||
class ThumbnailDiskCache(
|
class ThumbnailDiskCache(
|
||||||
private val cacheDir: File,
|
private val cacheDir: File,
|
||||||
@@ -45,6 +48,9 @@ class ThumbnailDiskCache(
|
|||||||
const val JPEG_QUALITY = 80
|
const val JPEG_QUALITY = 80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks URLs currently being written to prevent duplicate saves
|
||||||
|
private val inFlight = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
cacheDir.mkdirs()
|
cacheDir.mkdirs()
|
||||||
}
|
}
|
||||||
@@ -56,16 +62,40 @@ class ThumbnailDiskCache(
|
|||||||
return if (file.exists()) file else null
|
return if (file.exists()) file else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a bitmap as a JPEG thumbnail. Uses atomic write (temp file + rename)
|
||||||
|
* to prevent readers from seeing a partially written file.
|
||||||
|
*
|
||||||
|
* Returns true if saved, false if already in flight or on error.
|
||||||
|
*/
|
||||||
fun save(
|
fun save(
|
||||||
url: String,
|
url: String,
|
||||||
bitmap: Bitmap,
|
bitmap: Bitmap,
|
||||||
): File {
|
): Boolean {
|
||||||
val file = File(cacheDir, keyFor(url))
|
// Skip if another coroutine is already saving this URL
|
||||||
file.outputStream().use { out ->
|
if (!inFlight.add(url)) return false
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
|
||||||
|
try {
|
||||||
|
val key = keyFor(url)
|
||||||
|
val finalFile = File(cacheDir, key)
|
||||||
|
|
||||||
|
// Already saved by another coroutine that finished before us
|
||||||
|
if (finalFile.exists()) return true
|
||||||
|
|
||||||
|
// Write to temp file, then atomically rename
|
||||||
|
val tempFile = File(cacheDir, "$key.tmp")
|
||||||
|
tempFile.outputStream().use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||||
|
}
|
||||||
|
tempFile.renameTo(finalFile)
|
||||||
|
|
||||||
|
evictIfNeeded()
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
inFlight.remove(url)
|
||||||
}
|
}
|
||||||
evictIfNeeded()
|
|
||||||
return file
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decodeThumbnail(file: File): Bitmap? =
|
fun decodeThumbnail(file: File): Bitmap? =
|
||||||
|
|||||||
Reference in New Issue
Block a user