How FileCacheStorage (the shared engine in cache-core, used by every backend) keeps entries
on disk: what it guarantees, and what it does not.
The storage engine (FileCacheStorage) and the backend SPI (CacheFileSystem,
CacheFileMetadata) stay behind @InternalPersistentCacheApi. Applications reach it through:
install(PersistentCache) { ... }incache-core, or- a per-backend factory returning Ktor's
CacheStorage, for use with Ktor's ownHttpCache:OkioCacheStorage(...)(cache-okio, stable) andKotlinxIoCacheStorage(...)(cache-kotlinx-io,@ExperimentalKotlinxIoCache).
install(HttpCache) {
publicStorage(OkioCacheStorage(directory = "http_cache", maxSize = 10L * 1024 * 1024, ttl = 3_600_000))
}The factories and PersistentCacheConfig share the same defaults ("http_cache", 10 MiB,
1 hour) and the same engine, so everything below applies to both. The factories have distinct
names so both backend modules can be on the classpath.
Each entry is one file under <cache root>/<directory>/:
<sha256 hex of the URL, 64 chars>_<vary hash>.cache
vary hashis0when the response has noVarykeys, otherwise the first 16 hex chars of the SHA-256 of the sortedkey=value;key=valuestring.- The file starts with a 4-byte header (
K,P,C, format version1) followed by the ProtoBuf payload. A file with a missing or unknown header is treated as corrupt and deleted. findalso checks that the stored vary keys equal the requested ones, so two different vary sets can never be served from each other's entry.- Writes go to
<name>.tmpnext to the target and are then atomically moved onto it.*.tmpfiles never end in.cache, so lookups ignore them; leftovers from a crash are deleted when a storage starts and byclear().
Verified against the kotlinx-io 0.9.1 and Okio 3.18.2 sources (the version pinned in
gradle/libs.versions.toml): the move replaces an existing target on every target this
project builds.
| Target | kotlinx-io 0.9.1 | Okio 3.18.2 |
|---|---|---|
| JVM, Android API 26+ (incl. Windows JVM) | FileSystemJvm.kt NioMover: Files.move(ATOMIC_MOVE, REPLACE_EXISTING) |
NioSystemFileSystem.atomicMove: same options |
Android API 24-25 (no java.nio.file) |
throws UnsupportedOperationException; the adapter falls back to a non-atomic direct write |
JvmSystemFileSystem: File.renameTo, replaces on POSIX |
| iOS, macOS, Linux native | atomicMoveImpl = rename(2) |
PosixFileSystem.variantMove = rename(2) |
| Windows native | not verified (not built here) | not verified (not built here) |
As a defence, KotlinxIoCacheFileSystem.writeAtomically deletes the destination and retries
once if the move fails with an IOException. On Android API 24-25 the kotlinx-io backend is
therefore not atomic; use the Okio backend there if torn writes matter.
Android is covered by testAndroidHostTest (:cache-core, :cache-okio, :cache-kotlinx-io),
which runs the common tests and conformance suites on the JVM against the Android source set and
android.jar stubs. It does not run on a device or emulator, and on the host the API 24-25
atomicMove path is only simulated by the fallback tests, never reached on a real API 24-25
runtime.
On startup the storage also deletes *.cache files whose name does not match the layout above
(files written by versions that used a 32-bit vary suffix). Existing caches from earlier
versions are therefore discarded once; this costs cache misses, never errors.
When maxSize > 0, every store ends with a cleanup that removes expired entries and then
evicts the least recently used entries until the total size fits maxSize.
- The storage keeps an in-memory index (
file -> size, stored timestamp, last access). It is built lazily by scanning the directory once, on the first cleanup, and then kept in sync bystore,find,findAll,remove,removeAllandclear. After that, cleanup reads no files. Memory cost is metadata only. - Recency is real LRU:
findandfindAllrefresh an entry's last access in memory. - After a restart the index is rebuilt from disk, and last access falls back to the stored timestamp, so eviction order is write order until entries are read again.
- With
maxSize <= 0no index is built and nothing is evicted by size.
- One
FileCacheStorageinstance per cache directory is the supported configuration. Calls on that instance are serialized by an internal mutex. - Atomic writes mean a concurrent reader (for example a second instance reading the same directory) never observes a half-written entry.
- Two instances (or processes) that write or evict in the same directory are unsupported: each keeps its own index and may delete files the other still counts. The worst outcome is a cache miss, but there is no cross-instance or cross-process lock.
There are two independent clocks:
- Storage TTL (
ttl, this library). An entry older thanttlmilliseconds, measured from when it was stored, is dropped byfind,findAlland cleanup.ttl <= 0disables it. - HTTP freshness (Ktor
HttpCache).CachedResponseData.expiresis computed by Ktor fromCache-Control: max-age/s-maxage, or theExpiresheader, and stored with the entry.
Verified against the Ktor 3.6.0 sources (HttpCache.kt, HttpCacheEntry.kt): after
storage.find(...) returns an entry, HttpCache calls shouldValidate(cache.expires, ...). A
response whose expires is in the future is served without a network call; a stale one is
revalidated (If-None-Match / If-Modified-Since from the cached headers), or refetched, and
must-revalidate, no-cache and max-stale are honoured. So HTTP freshness is already
enforced by Ktor from expires, and this library does not duplicate it.
ttl therefore acts as an upper bound on how long an entry is retained on disk, not as the
freshness rule. An entry can be HTTP-stale yet still stored (it stays useful for conditional
revalidation), and an HTTP-fresh entry is still removed once it outlives ttl.