Skip to content

Migrate FileCacheStorage to common using kotlinx.io #4940

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.client.plugins.cache.storage

import io.ktor.http.Url
import io.ktor.util.collections.ConcurrentMap

internal class CachingCacheStorage(
private val delegate: CacheStorage
) : CacheStorage {

private val store = ConcurrentMap<Url, Set<CachedResponseData>>()

override suspend fun store(url: Url, data: CachedResponseData) {
delegate.store(url, data)
store[url] = delegate.findAll(url)
}

override suspend fun find(url: Url, varyKeys: Map<String, String>): CachedResponseData? {
if (!store.containsKey(url)) {
store[url] = delegate.findAll(url)
}
val data = store.getValue(url)
return data.find {
varyKeys.all { (key, value) -> it.varyKeys[key] == value }
}
}

override suspend fun findAll(url: Url): Set<CachedResponseData> {
if (!store.containsKey(url)) {
store[url] = delegate.findAll(url)
}
return store.getValue(url)
}

override suspend fun remove(url: Url, varyKeys: Map<String, String>) {
delegate.remove(url, varyKeys)
store[url] = delegate.findAll(url)
}

override suspend fun removeAll(url: Url) {
delegate.removeAll(url)
store.remove(url)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,81 +4,46 @@

package io.ktor.client.plugins.cache.storage

import io.ktor.client.engine.*
import io.ktor.client.plugins.cache.*
import io.ktor.http.*
import io.ktor.util.*
import io.ktor.util.collections.*
import io.ktor.util.date.*
import io.ktor.util.logging.*
import io.ktor.utils.io.*
import io.ktor.utils.io.jvm.javaio.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
import java.io.*
import java.security.*
import kotlinx.io.*
import kotlinx.io.files.*

/**
* Creates storage that uses file system to store cache data.
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.cache.storage.FileStorage)
*
* @param fileSystem file system to use for file operations.
* @param directory directory to store cache data.
* @param dispatcher dispatcher to use for file operations.
*/
@Suppress("FunctionName")
public fun FileStorage(
directory: File,
dispatcher: CoroutineDispatcher = Dispatchers.IO
): CacheStorage = CachingCacheStorage(FileCacheStorage(directory, dispatcher))

internal class CachingCacheStorage(
private val delegate: CacheStorage
) : CacheStorage {

private val store = ConcurrentMap<Url, Set<CachedResponseData>>()

override suspend fun store(url: Url, data: CachedResponseData) {
delegate.store(url, data)
store[url] = delegate.findAll(url)
}

override suspend fun find(url: Url, varyKeys: Map<String, String>): CachedResponseData? {
if (!store.containsKey(url)) {
store[url] = delegate.findAll(url)
}
val data = store.getValue(url)
return data.find {
varyKeys.all { (key, value) -> it.varyKeys[key] == value }
}
}

override suspend fun findAll(url: Url): Set<CachedResponseData> {
if (!store.containsKey(url)) {
store[url] = delegate.findAll(url)
}
return store.getValue(url)
}

override suspend fun remove(url: Url, varyKeys: Map<String, String>) {
delegate.remove(url, varyKeys)
store[url] = delegate.findAll(url)
}

override suspend fun removeAll(url: Url) {
delegate.removeAll(url)
store.remove(url)
}
}
fileSystem: FileSystem,
directory: Path,
dispatcher: CoroutineDispatcher = ioDispatcher()
): CacheStorage = CachingCacheStorage(FileCacheStorage(fileSystem, directory, dispatcher))

private class FileCacheStorage(
private val directory: File,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
private val fileSystem: FileSystem,
private val directoryPath: Path,
private val dispatcher: CoroutineDispatcher = ioDispatcher()
) : CacheStorage {

private val mutexes = ConcurrentMap<String, Mutex>()

init {
directory.mkdirs()
fileSystem.createDirectories(directoryPath)
}

override suspend fun store(url: Url, data: CachedResponseData): Unit = withContext(dispatcher) {
Expand Down Expand Up @@ -111,7 +76,7 @@ private class FileCacheStorage(
deleteCache(urlHex)
}

private fun key(url: Url) = hex(MessageDigest.getInstance("SHA-256").digest(url.toString().encodeToByteArray()))
private fun key(url: Url) = hex(sha256(url.toString().encodeToByteArray()))

private suspend fun readCache(urlHex: String): Set<CachedResponseData> {
val mutex = mutexes.computeIfAbsent(urlHex) { Mutex() }
Expand All @@ -132,11 +97,11 @@ private class FileCacheStorage(
private suspend fun deleteCache(urlHex: String) {
val mutex = mutexes.computeIfAbsent(urlHex) { Mutex() }
mutex.withLock {
val file = File(directory, urlHex)
if (!file.exists()) return@withLock
val path = Path(directoryPath, urlHex)
if (!fileSystem.exists(path)) return@withLock

try {
file.delete()
fileSystem.delete(path)
} catch (cause: Exception) {
LOGGER.trace { "Exception during cache deletion in a file: ${cause.stackTraceToString()}" }
}
Expand All @@ -146,7 +111,8 @@ private class FileCacheStorage(
private suspend fun writeCacheUnsafe(urlHex: String, caches: List<CachedResponseData>) = coroutineScope {
val channel = ByteChannel()
try {
File(directory, urlHex).outputStream().buffered().use { output ->
val path = Path(directoryPath, urlHex)
fileSystem.sink(path).buffered().use { output ->
launch {
channel.writeInt(caches.size)
for (cache in caches) {
Expand All @@ -162,12 +128,12 @@ private class FileCacheStorage(
}

private suspend fun readCacheUnsafe(urlHex: String): Set<CachedResponseData> {
val file = File(directory, urlHex)
if (!file.exists()) return emptySet()
val path = Path(directoryPath, urlHex)
if (!fileSystem.exists(path)) return emptySet()

try {
file.inputStream().buffered().use {
val channel = it.toByteReadChannel()
fileSystem.source(path).buffered().use { input ->
val channel = input.toByteReadChannel(dispatcher)
val requestsCount = channel.readInt()
val caches = mutableSetOf<CachedResponseData>()
for (i in 0 until requestsCount) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,27 @@ import io.ktor.client.plugins.cache.storage.*
import io.ktor.http.*
import io.ktor.util.date.*
import kotlinx.coroutines.test.*
import java.io.*
import kotlin.io.path.*
import kotlinx.io.files.*
import kotlin.test.*
import kotlin.uuid.*

class FileStorageTest {
private lateinit var tempDirectory: File
private lateinit var tempDirectory: Path

@BeforeTest
fun setUp() {
tempDirectory = createTempDirectory().toFile()
tempDirectory = temporaryDirectoryPath()
SystemFileSystem.createDirectories(tempDirectory)
}

@AfterTest
fun tearDown() {
tempDirectory.deleteRecursively()
SystemFileSystem.deleteRecursively(tempDirectory)
}

@Test
fun testFindAll() = runTest {
val storage = FileStorage(tempDirectory)
val storage = FileStorage(SystemFileSystem, tempDirectory)

storage.store(Url("http://example.com"), data())
storage.store(Url("http://example.com"), data(mapOf("key" to "value")))
Expand All @@ -35,7 +36,7 @@ class FileStorageTest {

@Test
fun testFind() = runTest {
val storage = FileStorage(tempDirectory)
val storage = FileStorage(SystemFileSystem, tempDirectory)

storage.store(Url("http://example.com"), data())
storage.store(Url("http://example.com"), data(mapOf("key" to "value")))
Expand All @@ -45,7 +46,7 @@ class FileStorageTest {

@Test
fun testStore() = runTest {
val storage = FileStorage(tempDirectory)
val storage = FileStorage(SystemFileSystem, tempDirectory)
storage.store(Url("http://example.com"), data())

assertEquals(1, storage.findAll(Url("http://example.com")).size)
Expand All @@ -57,7 +58,7 @@ class FileStorageTest {

@Test
fun testRemove() = runTest {
val storage = FileStorage(tempDirectory)
val storage = FileStorage(SystemFileSystem, tempDirectory)
storage.store(Url("http://example.com"), data())
storage.store(Url("http://example.com"), data(mapOf("key" to "value")))

Expand All @@ -70,7 +71,7 @@ class FileStorageTest {

@Test
fun testRemoveAll() = runTest {
val storage = FileStorage(tempDirectory)
val storage = FileStorage(SystemFileSystem, tempDirectory)
storage.store(Url("http://example.com"), data())
storage.store(Url("http://example.com"), data(mapOf("key" to "value")))

Expand All @@ -91,4 +92,22 @@ class FileStorageTest {
varyKeys,
ByteArray(0)
)

companion object {
@OptIn(ExperimentalUuidApi::class)
private fun temporaryDirectoryPath(): Path {
return Path(SystemTemporaryDirectory, Uuid.random().toString())
}

private fun FileSystem.deleteRecursively(directory: Path) {
for (subPath in list(directory)) {
if (metadataOrNull(subPath)?.isDirectory == true) {
deleteRecursively(subPath)
} else {
delete(subPath)
}
}
delete(directory)
}
}
}
Loading