Skip to content

Commit

Permalink
Multiplatform LRU + Memory normalized cache (#2878)
Browse files Browse the repository at this point in the history
* Multiplatform LRU cache

* Memory cache

* Feedback

* Rebase
  • Loading branch information
sav007 committed Jan 20, 2021
1 parent 6636d59 commit 0f2a34d
Show file tree
Hide file tree
Showing 14 changed files with 786 additions and 67 deletions.
Expand Up @@ -76,4 +76,4 @@ val <D : Operation.Data> Response<D>.fromCache
@ApolloExperimental
fun ApolloClient.Builder.normalizedCache(normalizedCache: NormalizedCache): ApolloClient.Builder {
return addInterceptor(ApolloCacheInterceptor(ApolloStore(normalizedCache)))
}
}
16 changes: 9 additions & 7 deletions apollo-normalized-cache/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
`java-library`
kotlin("multiplatform")
id("kotlinx-atomicfu")
}

kotlin {
Expand All @@ -27,16 +28,23 @@ kotlin {
dependencies {
api(project(":apollo-api"))
api(project(":apollo-normalized-cache-api"))
implementation(groovy.util.Eval.x(project, "x.dep.kotlin.atomic"))
}
}

val jvmMain by getting {
dependsOn(commonMain)
dependencies {
implementation(groovy.util.Eval.x(project, "x.dep.cache"))
}
}

val iosMain by getting {
}

val iosSimMain by getting {
dependsOn(iosMain)
}

val commonTest by getting {
dependencies {
implementation(kotlin("test-common"))
Expand All @@ -45,17 +53,11 @@ kotlin {
}

val jvmTest by getting {
dependsOn(jvmMain)
dependencies {
implementation(kotlin("test-junit"))
implementation(groovy.util.Eval.x(project, "x.dep.junit"))
implementation(groovy.util.Eval.x(project, "x.dep.truth"))
}
}

}
}

metalava {
hiddenPackages += setOf("com.apollographql.apollo.cache.normalized.internal")
}
@@ -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
}
}
}
}
@@ -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>?,
)
}
@@ -0,0 +1,5 @@
package com.apollographql.apollo.cache.normalized.internal

expect object Platform {
fun currentTimeMillis(): Long
}

This file was deleted.

0 comments on commit 0f2a34d

Please sign in to comment.