The default that turned 55 GB into 11,300 files
Media3 ships cache fragments at 5 MiB, a number meant for streaming. In a download library it becomes 11,300 files and micro-stutters during playback. Why we raised it to 512 MiB—and why only in one of the two caches.
A downloaded movie, on the TV's disk, no network. After a few minutes, the video stops for half a second and continues. Another stutter two minutes later. Nothing crashes, so nothing tells you where to look: the file is whole, the network isn't involved, and the player hasn't done anything weird.
In LoverCast, that was the symptom. The cause wasn't in the player or the download: it was in a number Media3 picks for you if you don't pick it yourself. 5 MiB.
Two caches with opposite rules
The app stores video on disk with SimpleCache, Media3's cache. Two caches that look nothing alike live in the same process:
- The streaming one, where VOD lands as it's watched. It lives in internal storage and has a cap and an LRU evictor: 500 MB on TV, 256 MB on mobile. When it fills up, it evicts the least recently used.
- The downloads one, where movies and shows downloaded for offline viewing live. It has no evictor (
NoOpCacheEvictor): it never evicts. If someone downloads 55 GB, they take up 55 GB, usually on a USB drive plugged into the TV.
SimpleCache doesn't store one file per movie. It stores them in spans: contiguous chunks, each with its own .exo file on disk. The chunk size is set with CacheDataSink.Factory.setFragmentSize(). And if you don't set it, it's already set: CacheDataSink.DEFAULT_FRAGMENT_SIZE is 5 MiB.
That default isn't wrong. It's designed for what Media3 expects to find: a streaming cache, small and bounded by LRU. The problem is that we were also using it in the other cache, the one that looks nothing like it.
The arithmetic
In a downloads cache, 5 MiB stops being a reasonable number. 55 GB at 5 MiB per fragment is about 11,300 files. And the cost of SimpleCache is linear in the number of spans. Three costs, specifically:
getSpan()does aFile.length()on every fragment open. Astat(). And it does it inside the cache'ssynchronizedmonitor. On an external USB HDD, with FUSE in between, astat()isn't a memory lookup: it's a trip to the disk.removeStaleSpans()turns a failure into a cascade. If the length of a single file doesn't match the index, it walks every span of every download doing astat()for each one, with the lock held, in a loop until it converges. While that's happening, the ExoPlayer loader is waiting: the buffer drains and the micro-stutter appears.commitFile()writes a row and an SQLite transaction per fragment. And that database isn't exclusive: it's shared by the process's threeStandaloneDatabaseProviderinstances — the streaming cache, the downloads cache, and theDownloadManagerindex.
None of the three shows up in a test. All three grow with the size of the library, not with what's used. And the symptom —a stutter during playback— is several layers away from the number causing it.
512 MiB, and not one more
The decision was to raise the download cache's fragment size to 512 MiB:
const val OFFLINE_FRAGMENT_SIZE_BYTES = 512L * 1024 * 1024 // 512 MiB
The same 55 GB becomes about 110 files, a hundred times fewer. The three costs above turn into noise.
The number isn't 'the bigger the better,' and that's the detail worth telling. There's a ceiling we don't control: the external disk may come formatted as FAT32, which doesn't allow files larger than 4 GiB. 512 MiB stays well below that, so the fragment fits on any disk the user plugs in without us having to ask about their file system.
And it wasn't applied to the other cache. The streaming one keeps its 5 MiB on purpose: it has an LRU evictor, and LRU evicts whole spans. With 512 MiB fragments, each eviction would take out half a movie just to free up space. In a cache capped at 500 MB, besides, not even two fragments fit. The right size depends on the cache's policy, not on what's being downloaded.
Where it's set
The fragment size lives in a single place, VideoCacheProvider.offlineCacheWriteDataSinkFactory(), and it's used by the two paths that write to that cache:
fun offlineCacheWriteDataSinkFactory(): CacheDataSink.Factory =
CacheDataSink.Factory()
.setCache(offlineCache)
.setFragmentSize(OFFLINE_FRAGMENT_SIZE_BYTES)
One is DownloadManager, which performs downloads. The other is playback: if a download is missing chunks, the player requests them and writes them to the same cache. If we'd changed only one, the other would still be seeding 5 MiB files.
The first one hid a trap: DownloadManager's convenience constructor doesn't let you inject a write sink. We had to build it explicitly with DefaultDownloadIndex + DefaultDownloaderFactory to be able to pass it the factory with the large fragment. It's more code than we'd have liked, but the number stays in one place and there aren't two ways to download.
The cost that doesn't go away
This only affects new downloads. The fragment size is set when data is written, so what's already downloaded keeps its 5 MiB files forever. For an old download to benefit, you have to delete it and download it again.
It's ugly and it's the truth, so it's written in the doc next to the constant. The alternative —a migration that rewrites fragments— would mean moving tens of GB for an improvement that only matters when the library is large. It's not worth it. What is worth it is that the next person who reads that 512 knows where it comes from and doesn't 'fix' it thinking it's an exaggeration.
What we take away
A default isn't neutral: it's someone else's decision for someone else's problem. 5 MiB is correct in a bounded streaming cache and disastrous in a download library. The number didn't change value: the regime it was used in changed. Before accepting an inherited value, it's worth asking which cache it was meant for.
When the cost is linear in the number of files, the symptom doesn't appear where the number is. It appears in the player, as a stutter, and from there the natural path takes you to look at codecs and network. The clue was the arithmetic: 55 GB at 5 MiB isn't a library, it's 11,300 files.
The size of a write and the size of an eviction don't have to be the same. They're two caches with two policies, and what makes one happy ruins the other. Copying the parameter from one to the other for symmetry would have been exactly the inverse mistake.
A downloaded movie playing all the way through still goes unnoticed. The change was a 5 for a 512, and that doesn't make any headline. Which is exactly how it should be.
LoverCast is in production and you can download it. If you have a cache that chokes on its own index, tell us about it.