Skip to content

Commit a9cac7e

Browse files
Implement tests, fixes, and new functionality for OkioFileKache (#157)
* Add OkioFileKacheTest for common use cases This commit also implements fixes for clear() and re-putting an existing value * Update journal to version 3 This version stores keys in its plain form instead of transformed form. It also checks for strategy used * Make `FileKache`s more in par with InMemoryKache - The commit adds tests for newly added functions - The commit also fixes removal of cancelled putting
1 parent 06717bd commit a9cac7e

File tree

14 files changed

+924
-162
lines changed

14 files changed

+924
-162
lines changed

file-kache/src/commonMain/kotlin/com/mayakapps/kache/FileKache.kt

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.mayakapps.kache
1818

1919
import com.mayakapps.kache.FileKache.Configuration
2020
import kotlinx.coroutines.CoroutineScope
21+
import kotlinx.coroutines.CoroutineStart
2122
import kotlinx.coroutines.Deferred
2223
import kotlinx.coroutines.async
2324
import okio.Path
@@ -44,11 +45,18 @@ public class FileKache internal constructor(
4445
private val creationScope: CoroutineScope,
4546
) : ContainerKache<String, String> {
4647

47-
override suspend fun get(key: String): String? =
48-
baseKache.get(key)?.toString()
48+
override val maxSize: Long get() = baseKache.maxSize
49+
override val size: Long get() = baseKache.size
4950

50-
override suspend fun getIfAvailable(key: String): String? =
51-
baseKache.getIfAvailable(key)?.toString()
51+
override suspend fun getKeys(): Set<String> = baseKache.getKeys()
52+
53+
override suspend fun getUnderCreationKeys(): Set<String> = baseKache.getUnderCreationKeys()
54+
55+
override suspend fun getAllKeys(): KacheKeys<String> = baseKache.getAllKeys()
56+
57+
override suspend fun get(key: String): String? = baseKache.get(key)?.toString()
58+
59+
override suspend fun getIfAvailable(key: String): String? = baseKache.getIfAvailable(key)?.toString()
5260

5361
override suspend fun getOrPut(key: String, creationFunction: suspend (String) -> Boolean): String? =
5462
baseKache.getOrPut(key) { file ->
@@ -61,20 +69,35 @@ public class FileKache internal constructor(
6169
}?.toString()
6270

6371
override suspend fun putAsync(key: String, creationFunction: suspend (String) -> Boolean): Deferred<String?> =
64-
creationScope.async {
72+
creationScope.async(start = CoroutineStart.UNDISPATCHED) {
6573
baseKache.putAsync(key) { file ->
6674
creationFunction(file.toString())
6775
}.await()?.toString()
6876
}
6977

70-
override suspend fun remove(key: String): Unit =
78+
override suspend fun remove(key: String) {
7179
baseKache.remove(key)
80+
}
7281

73-
override suspend fun clear(): Unit =
82+
override suspend fun clear() {
7483
baseKache.clear()
84+
}
7585

76-
override suspend fun close(): Unit =
86+
override suspend fun removeAllUnderCreation() {
87+
baseKache.removeAllUnderCreation()
88+
}
89+
90+
override suspend fun resize(maxSize: Long) {
91+
baseKache.resize(maxSize)
92+
}
93+
94+
override suspend fun trimToSize(size: Long) {
95+
baseKache.trimToSize(size)
96+
}
97+
98+
override suspend fun close() {
7799
baseKache.close()
100+
}
78101

79102
/**
80103
* Configuration for [FileKache]. It is used as a receiver of [FileKache] builder which is [invoke].

file-kache/src/commonMain/kotlin/com/mayakapps/kache/OkioFileKache.kt

Lines changed: 110 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,12 @@ package com.mayakapps.kache
1818

1919
import com.mayakapps.kache.OkioFileKache.Configuration
2020
import com.mayakapps.kache.journal.*
21-
import kotlinx.coroutines.CoroutineScope
22-
import kotlinx.coroutines.Deferred
23-
import kotlinx.coroutines.invoke
24-
import kotlinx.coroutines.launch
21+
import kotlinx.coroutines.*
2522
import kotlinx.coroutines.sync.Mutex
2623
import kotlinx.coroutines.sync.withLock
2724
import okio.EOFException
2825
import okio.FileSystem
2926
import okio.Path
30-
import okio.Path.Companion.toPath
3127
import okio.buffer
3228

3329
/**
@@ -58,100 +54,136 @@ public class OkioFileKache private constructor(
5854

5955
// Explicit type parameter is a workaround for https://youtrack.jetbrains.com/issue/KT-53109
6056
@Suppress("RemoveExplicitTypeArguments")
61-
private val underlyingKache = InMemoryKache<String, Path>(maxSize = maxSize) {
57+
private val underlyingKache = InMemoryKache<String, String>(maxSize = maxSize) {
6258
this.strategy = strategy
63-
this.sizeCalculator = { _, file -> fileSystem.metadata(file).size ?: 0 }
64-
this.onEntryRemoved = { _, key, oldValue, _ -> onEntryRemoved(key, oldValue) }
59+
this.sizeCalculator = { _, filename -> fileSystem.metadata(filesDirectory.resolve(filename)).size ?: 0 }
60+
this.onEntryRemoved = { _, key, oldValue, newValue -> onEntryRemoved(key, oldValue, newValue) }
6561
this.creationScope = this@OkioFileKache.creationScope
6662
}
6763

64+
private val filesDirectory = directory.resolve(FILES_DIR)
65+
6866
private val journalMutex = Mutex()
6967
private val journalFile = directory.resolve(JOURNAL_FILE)
7068
private var journalWriter =
7169
JournalWriter(fileSystem.appendingSink(journalFile, mustExist = true).buffer())
7270

7371
private var redundantJournalEntriesCount = initialRedundantJournalEntriesCount
72+
override val maxSize: Long get() = underlyingKache.maxSize
73+
override val size: Long get() = underlyingKache.size
74+
75+
override suspend fun getKeys(): Set<String> = underlyingKache.getKeys()
76+
77+
override suspend fun getUnderCreationKeys(): Set<String> = underlyingKache.getUnderCreationKeys()
78+
79+
override suspend fun getAllKeys(): KacheKeys<String> = underlyingKache.getAllKeys()
7480

7581
override suspend fun get(key: String): Path? {
76-
val transformedKey = key.transform()
77-
val result = underlyingKache.get(transformedKey)
78-
if (result != null) writeRead(transformedKey)
79-
return result
82+
val result = underlyingKache.get(key)
83+
if (result != null) writeRead(key)
84+
return result?.let { filesDirectory.resolve(it) }
8085
}
8186

8287
override suspend fun getIfAvailable(key: String): Path? {
83-
val transformedKey = key.transform()
84-
val result = underlyingKache.getIfAvailable(transformedKey)
85-
if (result != null) writeRead(transformedKey)
86-
return result
88+
val result = underlyingKache.getIfAvailable(key)
89+
if (result != null) writeRead(key)
90+
return result?.let { filesDirectory.resolve(it) }
8791
}
8892

8993
override suspend fun getOrPut(key: String, creationFunction: suspend (Path) -> Boolean): Path? {
9094
var created = false
91-
val transformedKey = key.transform()
92-
val result = underlyingKache.getOrPut(transformedKey) {
95+
val result = underlyingKache.getOrPut(key) {
9396
created = true
9497
wrapCreationFunction(it, creationFunction)
9598
}
9699

97-
if (!created && result != null) writeRead(transformedKey)
98-
return result
100+
if (!created && result != null) writeRead(key)
101+
return result?.let { filesDirectory.resolve(it) }
99102
}
100103

101-
override suspend fun put(key: String, creationFunction: suspend (Path) -> Boolean): Path? =
102-
underlyingKache.put(key.transform()) { wrapCreationFunction(it, creationFunction) }
104+
override suspend fun put(key: String, creationFunction: suspend (Path) -> Boolean): Path? {
105+
val filename = underlyingKache.put(key) { wrapCreationFunction(it, creationFunction) }
106+
return if (filename != null) filesDirectory.resolve(filename) else null
107+
}
103108

104109
override suspend fun putAsync(key: String, creationFunction: suspend (Path) -> Boolean): Deferred<Path?> =
105-
underlyingKache.putAsync(key.transform()) { wrapCreationFunction(it, creationFunction) }
110+
creationScope.async(start = CoroutineStart.UNDISPATCHED) {
111+
underlyingKache.putAsync(key) { wrapCreationFunction(it, creationFunction) }.await()?.let {
112+
filesDirectory.resolve(it)
113+
}
114+
}
106115

107116
override suspend fun remove(key: String) {
108117
// It's fine to consider the file is dirty now. Even if removal failed it's scheduled for
109-
val transformedKey = key.transform()
110-
writeDirty(transformedKey)
111-
underlyingKache.remove(transformedKey)
118+
writeDirty(key)
119+
underlyingKache.remove(key)
112120
}
113121

114122
override suspend fun clear() {
115-
close()
116-
if (fileSystem.metadata(directory).isDirectory) fileSystem.deleteRecursively(directory)
117-
fileSystem.createDirectories(directory)
123+
underlyingKache.getKeys().forEach { writeDirty(it) }
124+
underlyingKache.clear()
125+
}
126+
127+
override suspend fun removeAllUnderCreation() {
128+
underlyingKache.removeAllUnderCreation()
129+
}
130+
131+
override suspend fun resize(maxSize: Long) {
132+
underlyingKache.resize(maxSize)
133+
}
134+
135+
override suspend fun trimToSize(size: Long) {
136+
underlyingKache.trimToSize(size)
118137
}
119138

120139
override suspend fun close() {
121140
underlyingKache.removeAllUnderCreation()
122141
journalMutex.withLock { journalWriter.close() }
123142
}
124143

125-
private suspend fun String.transform() =
126-
keyTransformer?.transform(this) ?: this
127-
128144
private suspend fun wrapCreationFunction(
129145
key: String,
130146
creationFunction: suspend (Path) -> Boolean,
131-
): Path? {
132-
val tempFile = directory.resolve(key + TEMP_EXT)
133-
val cleanFile = directory.resolve(key)
134-
135-
writeDirty(key)
136-
return if (creationFunction(tempFile) && fileSystem.exists(tempFile)) {
137-
fileSystem.atomicMove(tempFile, cleanFile, deleteTarget = true)
138-
fileSystem.delete(tempFile)
139-
writeClean(key)
140-
rebuildJournalIfRequired()
141-
cleanFile
142-
} else {
147+
): String? {
148+
val transformedKey = keyTransformer?.transform(key) ?: key
149+
val tempFile = filesDirectory.resolve(transformedKey + TEMP_EXT)
150+
val cleanFile = filesDirectory.resolve(transformedKey)
151+
val isReplacing = fileSystem.exists(cleanFile)
152+
153+
try {
154+
writeDirty(key)
155+
return if (creationFunction(tempFile) && fileSystem.exists(tempFile)) {
156+
fileSystem.atomicMove(tempFile, cleanFile, deleteTarget = true)
157+
fileSystem.delete(tempFile)
158+
159+
if (isReplacing) writeClean(key)
160+
else writeClean(key, transformedKey)
161+
162+
rebuildJournalIfRequired()
163+
transformedKey
164+
} else {
165+
fileSystem.delete(tempFile)
166+
writeCancel(key)
167+
rebuildJournalIfRequired()
168+
null
169+
}
170+
} catch (th: Throwable) {
143171
fileSystem.delete(tempFile)
172+
144173
writeCancel(key)
145174
rebuildJournalIfRequired()
146-
null
175+
176+
throw th
147177
}
148178
}
149179

150-
private fun onEntryRemoved(key: String, oldValue: Path) {
151-
creationScope.launch {
152-
fileSystem.delete(oldValue)
153-
fileSystem.delete((oldValue.toString() + TEMP_EXT).toPath())
180+
private fun onEntryRemoved(key: String, oldValue: String, newValue: String?) {
181+
if (newValue != null) return
154182

183+
fileSystem.delete(filesDirectory.resolve(oldValue))
184+
fileSystem.delete(filesDirectory.resolve(oldValue + TEMP_EXT))
185+
186+
creationScope.launch(start = CoroutineStart.UNDISPATCHED) {
155187
writeRemove(key)
156188
rebuildJournalIfRequired()
157189
}
@@ -161,8 +193,8 @@ public class OkioFileKache private constructor(
161193
journalWriter.writeDirty(key)
162194
}
163195

164-
private suspend fun writeClean(key: String) = journalMutex.withLock {
165-
journalWriter.writeClean(key)
196+
private suspend fun writeClean(key: String, transformedKey: String? = null) = journalMutex.withLock {
197+
journalWriter.writeClean(key, transformedKey)
166198
redundantJournalEntriesCount++
167199
}
168200

@@ -191,7 +223,11 @@ public class OkioFileKache private constructor(
191223
journalWriter.close()
192224

193225
val (cleanKeys, dirtyKeys) = underlyingKache.getAllKeys()
194-
fileSystem.writeJournalAtomically(directory, cleanKeys, dirtyKeys)
226+
val cleanEntries = cleanKeys.mapNotNull { key ->
227+
val transformedKey = underlyingKache.getIfAvailable(key) ?: return@mapNotNull null
228+
key to transformedKey
229+
}.toMap()
230+
fileSystem.writeJournalAtomically(directory, cleanEntries, dirtyKeys)
195231

196232
journalWriter =
197233
JournalWriter(fileSystem.appendingSink(journalFile, mustExist = true).buffer())
@@ -255,39 +291,50 @@ public class OkioFileKache private constructor(
255291
): OkioFileKache {
256292
require(maxSize > 0) { "maxSize must be positive value" }
257293

258-
// Make sure that journal directory exists
294+
// Make sure that directories exist
295+
val filesDirectory = directory.resolve(FILES_DIR)
259296
fileSystem.createDirectories(directory)
297+
fileSystem.createDirectories(filesDirectory)
260298

261299
val journalData = try {
262-
fileSystem.readJournalIfExists(directory, cacheVersion)
300+
fileSystem.readJournalIfExists(directory, cacheVersion, strategy)
263301
} catch (ex: JournalException) {
264302
// Journal is corrupted - Clear cache
265303
fileSystem.deleteContents(directory)
304+
fileSystem.createDirectories(filesDirectory)
266305
null
267306
} catch (ex: EOFException) {
268307
// Journal is corrupted - Clear cache
269308
fileSystem.deleteContents(directory)
309+
fileSystem.createDirectories(filesDirectory)
270310
null
271311
}
272312

273313
// Delete dirty entries
274314
if (journalData != null) {
275-
for (key in journalData.dirtyEntriesKeys) {
276-
fileSystem.delete(directory.resolve(key + TEMP_EXT))
315+
for (key in journalData.dirtyEntryKeys) {
316+
fileSystem.delete(filesDirectory.resolve(key + TEMP_EXT))
277317
}
278318
}
279319

280320
// Rebuild journal if required
281321
var redundantJournalEntriesCount = journalData?.redundantEntriesCount ?: 0
282322

283323
if (journalData == null) {
284-
fileSystem.writeJournalAtomically(directory, emptyList(), emptyList())
324+
fileSystem.writeJournalAtomically(directory, emptyMap(), emptyList())
285325
} else if (
286326
journalData.redundantEntriesCount >= REDUNDANT_ENTRIES_THRESHOLD &&
287-
journalData.redundantEntriesCount >= journalData.cleanEntriesKeys.size
327+
journalData.redundantEntriesCount >= journalData.cleanEntries.size
288328
) {
289-
fileSystem
290-
.writeJournalAtomically(directory, journalData.cleanEntriesKeys, emptyList())
329+
for ((key, transformedKey) in journalData.cleanEntries) {
330+
if (transformedKey == null) {
331+
journalData.cleanEntries[key] = keyTransformer?.transform(key) ?: key
332+
}
333+
}
334+
335+
@Suppress("UNCHECKED_CAST")
336+
val cleanEntries = journalData.cleanEntries as Map<String, String>
337+
fileSystem.writeJournalAtomically(directory, cleanEntries, emptyList())
291338
redundantJournalEntriesCount = 0
292339
}
293340

@@ -302,14 +349,15 @@ public class OkioFileKache private constructor(
302349
)
303350

304351
if (journalData != null) {
305-
cache.underlyingKache.putAll(journalData.cleanEntriesKeys.associateWith { directory.resolve(it) })
352+
@Suppress("UNCHECKED_CAST")
353+
cache.underlyingKache.putAll(journalData.cleanEntries as Map<String, String>)
306354
}
307355

308356
return cache
309357
}
310358

311359
private const val TEMP_EXT = ".tmp"
312-
private const val REDUNDANT_ENTRIES_THRESHOLD = 2000
360+
internal const val REDUNDANT_ENTRIES_THRESHOLD = 2000
313361
}
314362
}
315363

file-kache/src/commonMain/kotlin/com/mayakapps/kache/journal/Consts.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
package com.mayakapps.kache.journal
1818

1919
internal const val JOURNAL_MAGIC = "JOURNAL"
20-
internal const val JOURNAL_VERSION: Byte = 2
20+
internal const val JOURNAL_VERSION: Byte = 3
2121

2222
internal const val JOURNAL_FILE = "journal"
2323
internal const val JOURNAL_FILE_TEMP = "$JOURNAL_FILE.tmp"
2424
internal const val JOURNAL_FILE_BACKUP = "$JOURNAL_FILE.bkp"
25+
26+
internal const val FILES_DIR = "files"

0 commit comments

Comments
 (0)