El default que convertía 55 GB en 11.300 ficheros
Media3 trae los fragmentos de la caché a 5 MiB, un número pensado para streaming. En una biblioteca de descargas se convierte en 11.300 ficheros y en micro-parones durante la reproducción. Por qué lo subimos a 512 MiB, y por qué solo en una de las dos cachés.
Una peli descargada, en el disco de la tele, sin red. A los pocos minutos el vídeo se para medio segundo y sigue. Otro parón dos minutos después. Nada revienta, así que nada te dice dónde mirar: el fichero está entero, la red no participa y el reproductor no ha hecho nada raro.
En LoverCast el síntoma era ese. La causa no estaba en el reproductor ni en la descarga: estaba en un número que Media3 elige por ti si no lo eliges tú. 5 MiB.
Dos cachés con reglas opuestas
La app guarda vídeo en disco con SimpleCache, la caché de Media3. En el mismo proceso conviven dos cachés que no se parecen en nada:
- La de streaming, donde va cayendo el VOD mientras se ve. Vive en memoria interna y tiene tope y evictor LRU: 500 MB en TV, 256 MB en el móvil. Cuando se llena, expulsa lo menos usado.
- La de descargas, donde viven las pelis y series bajadas para ver sin red. No tiene evictor (
NoOpCacheEvictor): nunca expulsa. Si alguien se descarga 55 GB, ocupan 55 GB, normalmente en un disco USB enchufado a la tele.
SimpleCache no guarda un fichero por peli. Lo guarda en spans: trozos contiguos, cada uno con su fichero .exo en disco. El tamaño del trozo se fija con CacheDataSink.Factory.setFragmentSize(). Y si no lo fijas, viene fijado: CacheDataSink.DEFAULT_FRAGMENT_SIZE son 5 MiB.
Ese default no está mal. Está pensado para lo que Media3 espera encontrarse: una caché de streaming, pequeña y acotada por LRU. El problema es que nosotros lo estábamos usando también en la otra caché, la que no se parece en nada.
La aritmética
En una caché de descargas, 5 MiB deja de ser un número razonable. 55 GB a 5 MiB por fragmento son unos 11.300 ficheros. Y el coste de SimpleCache es lineal en el número de spans. Tres costes, en concreto:
getSpan()hace unFile.length()en cada apertura de fragmento. Unstat(). Y lo hace dentro del monitorsynchronizedde la caché. En un HDD externo por USB, con FUSE de por medio, unstat()no es una consulta a memoria: es ir al disco.removeStaleSpans()convierte un fallo en una cascada. Si la longitud de un solo fichero no cuadra con el índice, recorre todos los spans de todas las descargas haciendo unstat()por cada uno, con el lock cogido, en bucle hasta que converge. Mientras eso pasa, el loader de ExoPlayer está esperando: el buffer se drena y sale el micro-parón.commitFile()escribe una fila y una transacción SQLite por fragmento. Y esa base de datos no es exclusiva: la comparten las tres instancias deStandaloneDatabaseProviderdel proceso — la caché de streaming, la de descargas y el índice delDownloadManager.
Ninguno de los tres aparece en un test. Los tres crecen con el tamaño de la biblioteca, no con lo que se usa. Y el síntoma —un parón durante la reproducción— está a varias capas de distancia del número que lo causa.
512 MiB, y ni uno más
La decisión fue subir el fragmento de la caché de descargas a 512 MiB:
const val OFFLINE_FRAGMENT_SIZE_BYTES = 512L * 1024 * 1024 // 512 MiB
Los mismos 55 GB pasan a ser unos 110 ficheros, cien veces menos. Los tres costes de arriba se vuelven ruido.
El número no es "cuanto más, mejor", y ese es el detalle que merece la pena contar. Hay un techo que no controlamos: el disco externo puede venir formateado en FAT32, que no admite ficheros de más de 4 GiB. 512 MiB se queda muy por debajo, así que el fragmento cabe en cualquier disco que enchufe el usuario sin que tengamos que preguntarle su sistema de ficheros.
Y no se aplicó a la otra caché. La de streaming conserva sus 5 MiB a propósito: tiene evictor LRU, y el LRU expulsa spans enteros. Con fragmentos de 512 MiB, cada expulsión se llevaría por delante media peli para liberar sitio. En una caché con tope de 500 MB, además, no caben ni dos fragmentos. El tamaño correcto depende de la regla de la caché, no de lo que se descarga.
Dónde se fija
El fragmento vive en un único sitio, VideoCacheProvider.offlineCacheWriteDataSinkFactory(), y lo usan los dos caminos que escriben en esa caché:
fun offlineCacheWriteDataSinkFactory(): CacheDataSink.Factory =
CacheDataSink.Factory()
.setCache(offlineCache)
.setFragmentSize(OFFLINE_FRAGMENT_SIZE_BYTES)
Uno es el DownloadManager, que baja las descargas. El otro es la reproducción: si a una descarga le faltan trozos, el reproductor los pide y los escribe en la misma caché. Si solo hubiéramos cambiado uno, el otro seguiría sembrando ficheros de 5 MiB.
El primero escondía una trampa: el constructor de conveniencia de DownloadManager no deja inyectar un sink de escritura. Hubo que construirlo explícitamente con DefaultDownloadIndex + DefaultDownloaderFactory para poder pasarle la factoría con el fragmento grande. Es más código del que nos habría gustado, pero el número queda en un solo sitio y no hay dos formas de descargar.
El coste que no desaparece
Esto solo afecta a las descargas nuevas. El tamaño del fragmento se fija cuando se escribe, así que lo que ya está bajado conserva sus ficheros de 5 MiB para siempre. Para que una descarga vieja se beneficie hay que borrarla y volver a bajarla.
Es feo y es la verdad, así que está escrito en el doc junto a la constante. La alternativa —una migración que reescriba fragmentos— sería mover decenas de GB por una mejora que solo importa cuando la biblioteca es grande. No compensa. Lo que sí compensa es que el próximo que lea ese 512 sepa de dónde sale y no lo "arregle" pensando que es una exageración.
Lo que nos llevamos
Un default no es neutro: es la decisión de otra persona para el problema de otra persona. 5 MiB es correcto en una caché de streaming acotada y desastroso en una biblioteca de descargas. El número no cambió de valor: cambió el régimen en el que se usaba. Antes de aceptar un valor heredado, merece la pena preguntar para qué caché se pensó.
Cuando el coste es lineal en el número de ficheros, el síntoma no aparece en el sitio del número. Aparece en el reproductor, en forma de parón, y desde ahí el camino natural te lleva a mirar codecs y red. La pista era la aritmética: 55 GB a 5 MiB no son una biblioteca, son 11.300 ficheros.
El tamaño de una escritura y el de una expulsión no tienen por qué ser el mismo. Son dos cachés con dos políticas, y lo que hace feliz a una arruina a la otra. Copiar el parámetro de una a la otra por simetría habría sido exactamente el error inverso.
Que una peli descargada corra entera sigue sin verse. El cambio fue un 5 por un 512, y eso no sale en ningún titular. Que es justo como debería ser.
LoverCast está en producción y puedes descargarla. Si tienes una caché que se atraganta con su propio índice, cuéntanoslo.