Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Multiplatform LRU + Memory normalized cache (#2878)
* Multiplatform LRU cache * Memory cache * Feedback * Rebase
- Loading branch information
Showing
14 changed files
with
786 additions
and
67 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
...ized-cache/src/commonMain/kotlin/com/apollographql/apollo/cache/normalized/MemoryCache.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
package com.apollographql.apollo.cache.normalized | ||
|
||
import com.apollographql.apollo.api.internal.json.JsonReader | ||
import com.apollographql.apollo.cache.ApolloCacheHeaders | ||
import com.apollographql.apollo.cache.CacheHeaders | ||
import com.apollographql.apollo.cache.normalized.internal.LruCache | ||
import com.apollographql.apollo.cache.normalized.internal.MapJsonReader | ||
import com.apollographql.apollo.cache.normalized.internal.Platform | ||
import okio.internal.commonAsUtf8ToByteArray | ||
import kotlin.reflect.KClass | ||
|
||
/** | ||
* Memory (multiplatform) cache implementation based on recently used property (LRU). | ||
* | ||
* [maxSizeBytes] - the maximum size of bytes the cache may occupy. | ||
* [expireAfterMillis] - after what timeout each entry in the cache treated as expired. By default there is no timeout. | ||
* | ||
* Expired entries removed from the cache only on cache miss ([loadRecord] operation) and not removed from the cache automatically | ||
* (there is no any sort of GC that runs in the background). | ||
*/ | ||
class MemoryCache( | ||
private val maxSizeBytes: Int, | ||
private val expireAfterMillis: Long = -1, | ||
) : NormalizedCache() { | ||
private val lruCache = LruCache<String, CacheEntry>(maxSize = maxSizeBytes) { key, cacheEntry -> | ||
key.commonAsUtf8ToByteArray().size + (cacheEntry?.sizeInBytes ?: 0) | ||
} | ||
|
||
val size: Int | ||
get() = lruCache.size() | ||
|
||
override fun loadRecord(key: String, cacheHeaders: CacheHeaders): Record? { | ||
val cachedEntry = lruCache[key] | ||
return if (cachedEntry == null || cachedEntry.isExpired) { | ||
if (cachedEntry != null) { | ||
lruCache.remove(key) | ||
} | ||
nextCache?.loadRecord(key, cacheHeaders) | ||
} else { | ||
if (cacheHeaders.hasHeader(ApolloCacheHeaders.EVICT_AFTER_READ)) { | ||
lruCache.remove(key) | ||
} | ||
cachedEntry.record | ||
} | ||
} | ||
|
||
override fun clearAll() { | ||
lruCache.clear() | ||
nextCache?.clearAll() | ||
} | ||
|
||
override fun remove(cacheKey: CacheKey, cascade: Boolean): Boolean { | ||
val cachedEntry = lruCache.remove(cacheKey.key) | ||
if (cascade && cachedEntry != null) { | ||
for (cacheReference in cachedEntry.record.referencedFields()) { | ||
remove(CacheKey(cacheReference.key), true) | ||
} | ||
} | ||
|
||
val removeFromNextCacheResult = nextCache?.remove(cacheKey, cascade) ?: false | ||
|
||
return cachedEntry != null || removeFromNextCacheResult | ||
} | ||
|
||
override fun performMerge(apolloRecord: Record, oldRecord: Record?, cacheHeaders: CacheHeaders): Set<String> { | ||
return if (oldRecord == null) { | ||
lruCache[apolloRecord.key] = CacheEntry( | ||
record = apolloRecord, | ||
expireAfterMillis = expireAfterMillis | ||
) | ||
apolloRecord.keys() | ||
} else { | ||
oldRecord.mergeWith(apolloRecord).also { | ||
//re-insert to trigger new weight calculation | ||
lruCache[apolloRecord.key] = CacheEntry( | ||
record = oldRecord, | ||
expireAfterMillis = expireAfterMillis | ||
) | ||
} | ||
} | ||
} | ||
|
||
@OptIn(ExperimentalStdlibApi::class) | ||
override fun dump() = buildMap<KClass<*>, Map<String, Record>> { | ||
put(this@MemoryCache::class, lruCache.dump().mapValues { (_, entry) -> entry.record }) | ||
putAll(nextCache?.dump().orEmpty()) | ||
} | ||
|
||
internal fun clearCurrentCache() { | ||
lruCache.clear() | ||
} | ||
|
||
override fun stream(key: String, cacheHeaders: CacheHeaders): JsonReader? { | ||
return loadRecord(key, cacheHeaders)?.let { MapJsonReader(it) } | ||
} | ||
|
||
private class CacheEntry( | ||
val record: Record, | ||
val expireAfterMillis: Long | ||
) { | ||
val cachedAtMillis: Long = Platform.currentTimeMillis() | ||
|
||
val sizeInBytes: Int = record.sizeEstimateBytes() + 8 | ||
|
||
val isExpired: Boolean | ||
get() { | ||
return if (expireAfterMillis < 0) { | ||
false | ||
} else { | ||
Platform.currentTimeMillis() - cachedAtMillis >= expireAfterMillis | ||
} | ||
} | ||
} | ||
} |
160 changes: 160 additions & 0 deletions
160
...ache/src/commonMain/kotlin/com/apollographql/apollo/cache/normalized/internal/LruCache.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
package com.apollographql.apollo.cache.normalized.internal | ||
|
||
import kotlinx.atomicfu.locks.reentrantLock | ||
import kotlinx.atomicfu.locks.withLock | ||
|
||
internal typealias Weigher<Key, Value> = (Key, Value?) -> Int | ||
|
||
/** | ||
* Multiplatform LRU cache implementation. | ||
* | ||
* Implementation is based on usage of [LinkedHashMap] as a container for the cache and custom | ||
* double linked queue to track LRU property. | ||
* | ||
* [maxSize] - maximum size of the cache, can be anything bytes, number of entries etc. By default is number o entries. | ||
* [weigher] - to be called to calculate the estimated size (weight) of the cache entry defined by its [Key] and [Value]. | ||
* By default it returns 1. | ||
* | ||
* This implementation is thread safe guaranteed by global lock used for both read / write operations. | ||
* | ||
* Cache trim performed only on new entry insertion. | ||
*/ | ||
internal class LruCache<Key, Value>( | ||
private val maxSize: Int, | ||
private val weigher: Weigher<Key, Value> = { _, _ -> 1 } | ||
) { | ||
private val cache = LinkedHashMap<Key, Node<Key, Value>>(0, 0.75f) | ||
private var headNode: Node<Key, Value>? = null | ||
private var tailNode: Node<Key, Value>? = null | ||
private val lock = reentrantLock() | ||
private var size: Int = 0 | ||
|
||
operator fun get(key: Key): Value? { | ||
return lock.withLock { | ||
val node = cache[key] | ||
if (node != null) { | ||
moveNodeToHead(node) | ||
} | ||
node?.value | ||
} | ||
} | ||
|
||
operator fun set(key: Key, value: Value) { | ||
lock.withLock { | ||
val node = cache[key] | ||
if (node == null) { | ||
cache[key] = addNode(key, value) | ||
} else { | ||
node.value = value | ||
moveNodeToHead(node) | ||
} | ||
|
||
trim() | ||
} | ||
} | ||
|
||
fun remove(key: Key): Value? { | ||
return lock.withLock { | ||
val nodeToRemove = cache.remove(key) | ||
val value = nodeToRemove?.value | ||
if (nodeToRemove != null) { | ||
unlinkNode(nodeToRemove) | ||
} | ||
value | ||
} | ||
} | ||
|
||
fun clear() { | ||
lock.withLock { | ||
cache.clear() | ||
headNode = null | ||
tailNode = null | ||
size = 0 | ||
} | ||
} | ||
|
||
fun size(): Int { | ||
return lock.withLock { | ||
size | ||
} | ||
} | ||
|
||
fun dump(): Map<Key, Value> { | ||
return lock.withLock { | ||
cache.mapValues { (_, value) -> value.value as Value } | ||
} | ||
} | ||
|
||
private fun trim() { | ||
var nodeToRemove = tailNode | ||
while (nodeToRemove != null && size > maxSize) { | ||
cache.remove(nodeToRemove.key) | ||
unlinkNode(nodeToRemove) | ||
nodeToRemove = tailNode | ||
} | ||
} | ||
|
||
private fun addNode(key: Key, value: Value?): Node<Key, Value> { | ||
val node = Node( | ||
key = key, | ||
value = value, | ||
next = headNode, | ||
prev = null, | ||
) | ||
|
||
headNode = node | ||
|
||
if (node.next == null) { | ||
tailNode = headNode | ||
} else { | ||
node.next?.prev = headNode | ||
} | ||
|
||
size += weigher(key, value) | ||
|
||
return node | ||
} | ||
|
||
private fun moveNodeToHead(node: Node<Key, Value>) { | ||
if (node.prev == null) { | ||
return | ||
} | ||
|
||
node.prev?.next = node.next | ||
node.next?.prev = node.prev | ||
|
||
node.next = headNode?.next | ||
node.prev = null | ||
|
||
headNode?.prev = node | ||
headNode = node | ||
} | ||
|
||
private fun unlinkNode(node: Node<Key, Value>) { | ||
if (node.prev == null) { | ||
this.headNode = node.next | ||
} else { | ||
node.prev?.next = node.next | ||
} | ||
|
||
if (node.next == null) { | ||
this.tailNode = node.prev | ||
} else { | ||
node.next?.prev = node.prev | ||
} | ||
|
||
size -= weigher(node.key!!, node.value) | ||
|
||
node.key = null | ||
node.value = null | ||
node.next = null | ||
node.prev = null | ||
} | ||
|
||
private class Node<Key, Value>( | ||
var key: Key?, | ||
var value: Value?, | ||
var next: Node<Key, Value>?, | ||
var prev: Node<Key, Value>?, | ||
) | ||
} |
5 changes: 5 additions & 0 deletions
5
...ache/src/commonMain/kotlin/com/apollographql/apollo/cache/normalized/internal/Platform.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package com.apollographql.apollo.cache.normalized.internal | ||
|
||
expect object Platform { | ||
fun currentTimeMillis(): Long | ||
} |
56 changes: 0 additions & 56 deletions
56
.../commonMain/kotlin/com/apollographql/apollo/cache/normalized/simple/MapNormalizedCache.kt
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.