Skip to content
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

Cache Rewrite #49

Merged

Conversation

ychescale9
Copy link
Contributor

@ychescale9 ychescale9 commented Dec 28, 2019

Resolves #14

This is a rewrite of the cache module in Kotlin. The new implementation supports sized-based and time-based (time-to-live and time-to-idle) evictions.

Cache and Builder APIs

interface Cache<in Key, Value> {

    /**
     * Returns the value associated with [key] in this cache, or null if there is no
     * cached value for [key].
     */
    fun get(key: Key): Value?

    /**
     * Returns the value associated with [key] in this cache if exists,
     * otherwise gets the value by invoking [loader], associates the value with [key] in the cache,
     * and returns the cached value.
     *
     * Note that if an unexpired value for the [key] is present by the time the [loader] returns
     * the new value, the existing value won't be replaced by the new value. Instead the existing
     * value will be returned.
     */
    fun get(key: Key, loader: () -> Value): Value

    /**
     * Associates [value] with [key] in this cache. If the cache previously contained a
     * value associated with [key], the old value is replaced by [value].
     */
    fun put(key: Key, value: Value)

    /**
     * Discards any cached value for key [key].
     */
    fun invalidate(key: Key)

    /**
     * Discards all entries in the cache.
     */
    fun invalidateAll()

    /**
     * Main entry point for creating a [Cache].
     */
    interface Builder {

        /**
         * Specifies that each entry should be automatically removed from the cache once a fixed duration
         * has elapsed after the entry's creation or the most recent replacement of its value.
         *
         * When [duration] is zero, the cache's max size will be set to 0
         * meaning no values will be cached.
         */
        fun expireAfterWrite(duration: Long, unit: TimeUnit): Builder

        /**
         * Specifies that each entry should be automatically removed from the cache once a fixed duration
         * has elapsed after the entry's creation, the most recent replacement of its value, or its last
         * access.
         *
         * When [duration] is zero, the cache's max size will be set to 0
         * meaning no values will be cached.
         */
        fun expireAfterAccess(duration: Long, unit: TimeUnit): Builder

        /**
         * Specifies the maximum number of entries the cache may contain.
         * Cache eviction policy is based on LRU - i.e. least recently accessed entries get evicted first.
         *
         * When [size] is 0, entries will be discarded immediately and no values will be cached.
         *
         * If not set, cache size will be unlimited.
         */
        fun maximumCacheSize(size: Long): Builder

        /**
         * Guides the allowed concurrent update operations. This is used as a hint for internal sizing,
         * actual concurrency will vary.
         *
         * If not set, default concurrency level is 16.
         */
        fun concurrencyLevel(concurrencyLevel: Int): Builder

        /**
         * Specifies [Clock] for this cache.
         *
         * This is useful for controlling time in tests
         * where a fake [Clock] implementation can be provided.
         *
         * A [SystemClock] will be used if not specified.
         */
        fun clock(clock: Clock): Builder

        /**
         * Builds a new instance of [Cache] with the specified configurations.
         */
        fun <K : Any, V : Any> build(): Cache<K, V>
    }
}

Note that currently only APIs required by RealStore are supported. Please let know if more APIs are required.

Usage

// build a new cache
val cache = Cache.Builder.newBuilder()
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .expireAfterAccess(24, TimeUnit.HOURS)
            .maximumCacheSize(100)
            .build<Long, String>()

// cache a value
cache.put(1, "dog")

// get cached value by key
cache.get(1)

// get cached value by key, using the provided loader to compute and cache the value if none exists
val value = cache.get(2) { "cat" }

// remove a cached value by key
cache.invalidate(1)

// remove all values from cache
cache.invalidateAll()

Tests

./gradlew cache:test

100% Test Coverage:

cache4-coverage

Migration

All usages have been switched to use the new cache implementation.

I've also converted the remaining Java files to Kotlin, except MultiParserTest.java which is commented out.


Will update cache/README.md in a followup.

@digitalbuddha
Copy link
Contributor

Wow this is incredible! Thank you so much for all the hard work. I left one comment about cache loader otherwise looks good from first glance. After the holidays we will take this through a more detailed review


@Test
fun `get(key, loader) returns existing value when an entry with the associated key is absent initially but present after executing the loader`() =
runBlocking {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to use TestCoroutineScope.runBlockingTest here to advance the delays, but it doesn't seem to wait for the coroutines to finish when I'm launching with a different dispatcher: launch(Dispatchers.Default) which I need to be able to test concurrency. Is there a better way to test this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yigit has strategies for testing async coroutines maybe he can chime in

* the new value, the existing value won't be replaced by the new value. Instead the existing
* value will be returned.
*/
fun get(key: Key, loader: () -> Value): Value
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as far as i remember, we never use this loader in Store.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FileSystemImpl.kt was using the CacheLoader implementation from guava so I added this as suggested by @digitalbuddha

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you document what happens if loader throws an exception

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets also document that loader needs to be quick (i.e it's not a suspend function).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@digitalbuddha mentioned the loader could be expensive (currently used by filesystem module).
I also don't think we should make the loader suspend as we would have to also make the get(...) API itself suspend in order to execute it since the cache module does not depend on the coroutines library.

Can we document that the loader will be executed on the caller's thread instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we want to be a replacement for Store3, I figured we should respect this api in case there are consumers of the cache only

}

override fun get(key: Key, loader: () -> Value): Value {
synchronized(this) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets sync on an explicit object instead of this since this can be accessed outside the cache. I realize it is internal but it still means that can be accessed within the library

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not super granular, but I guess we can improve it in the future to be key based if we want to.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we verify if old behavior blocked per key or not? Ideally we should be granular since an app might have a single store used among many features

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I'll add key-based locks then.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added key-based synchronization with new tests.

*/
internal data class CacheEntry<Key, Value>(
val key: Key,
@Volatile var value: Value,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as far as i can read, these are always accessed inside a lock which will put a memory barrier hence you should not need volatile for these.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I’m not sure I follow. These entries live in the ConcurrentHashMap and a couple of synchronized LinkedHashSet but we are updating these values directly (eg when replacing the value for an existing key) instead of recreating and putting a new entry into the map. And currently only get(key: Key, () -> Value): Value is protected by an explicit synchronized block to prevent calling the potentially expensive loader function multiple times concurrently. So I think those mutable fields in the CacheEntry should be the only things we need to protect from concurrent access and hence the volatile. Am I missing something here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although using access to ConcurrentHashMap as a memory barrier should be enough, we do not write Entry back into the map after updating it so I don't believe we relay on it. If we wanted to drop Volatile here we would probably need to make these changes.

Whatever we end up with here (either volatile or using the map as a barrier), we should add some documentation here on why we're reusing entries (to be efficient) and document the concurrency policy if it's not obvious.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using map.put(...) as a barrier feels a bit subtle. Should we use an explicit lock to synchronize all updates to the CacheEntry objects?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've studied this a bit more and now I believe explicit lock or volatile shouldn't be necessary :)

Will remove volatile and add documentation about reuse.

@yigit
Copy link
Collaborator

yigit commented Dec 30, 2019

thanks for doing this btw, looks pretty good!

Copy link
Contributor

@eyalgu eyalgu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow nice PR. Thanks for doing all the work. I reviewed the interface and took a higher level look at the implementation and assumed that the tests covered most of the functionality. let me know if there's anything you'd like me to look at more closely.

@@ -0,0 +1,37 @@
package com.dropbox.android.external.cache4

interface Cache<in Key, Value> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key should be non-nullable, and in order to not have ambiguous get Value should be non-nullable too. Lets change to Cache<in Key: Any, Value: Any>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I've also updated the generic types in Store interface to be bound by Any.

* the new value, the existing value won't be replaced by the new value. Instead the existing
* value will be returned.
*/
fun get(key: Key, loader: () -> Value): Value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you document what happens if loader throws an exception

* the new value, the existing value won't be replaced by the new value. Instead the existing
* value will be returned.
*/
fun get(key: Key, loader: () -> Value): Value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets also document that loader needs to be quick (i.e it's not a suspend function).

*/
internal data class CacheEntry<Key, Value>(
val key: Key,
@Volatile var value: Value,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although using access to ConcurrentHashMap as a memory barrier should be enough, we do not write Entry back into the map after updating it so I don't believe we relay on it. If we wanted to drop Volatile here we would probably need to make these changes.

Whatever we end up with here (either volatile or using the map as a barrier), we should add some documentation here on why we're reusing entries (to be efficient) and document the concurrency policy if it's not obvious.

}

override fun get(key: Key, loader: () -> Value): Value {
synchronized(this) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not super granular, but I guess we can improve it in the future to be key based if we want to.

if (existingEntry != null) {
// cache entry found
existingEntry.recordWrite(nowNanos)
existingEntry.value = value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can add cacheEntries[key] = existingEntry to create a memory barrier using the map access.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason not to make the loader suspend rather than blocking? Could be follow up

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no CoroutineScope to execute the suspend function. If we made the get(key, loader) API itself suspend we will need to use Mutex which is also part of kotlinx-coroutines-core for synchronization. Or do we want to introduce coroutines library dependency to cache?

@ychescale9
Copy link
Contributor Author

@eyalgu @yigit Thanks for the review! Will address those comments soon.

- Update docs.
- Update Key, Value, Input, Output generic types to be bound by Any.
- Replace spy with TestLoader for asserting invocation.
Copy link
Contributor Author

@ychescale9 ychescale9 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe all comments have been addressed except whether loader should be suspend.
Again thanks for the review!

@@ -0,0 +1,37 @@
package com.dropbox.android.external.cache4

interface Cache<in Key, Value> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I've also updated the generic types in Store interface to be bound by Any.

}

override fun get(key: Key, loader: () -> Value): Value {
synchronized(this) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added key-based synchronization with new tests.

return keyBasedLocks.getLock(key).withLock {
action()
}.also {
keyBasedLocks.remove(key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need some sort of reference counting to make this work. you would have:
thread 1 takes lock.
thread 2 gets lock from map and waits
thread 1 finishes and removes key
thread 3 comes in and does not find a lock in map. proceeds with a new lock
thread 2 resumes on old lock

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTTOMH I can't think of a better way than a to add a counter to each lock entry in the map and have a global lock for adding/removing entries (that is aside from using Guava's ConcurrentHashMultiset which defeats the porpuse of rewriting this cache)

val LockMapSynchronizer = Any()
val keyBasedLocks = HashMap<Key, Pair<Lock, Int>>

private fun <Key> MutableMap<Key, Pair<Lock, Int>>.getLock(key: Key): Lock {
    synchronzied(lockMapSynchronizder) {
        val entry = get(key)
        if (entry != null) {
            entry.second++
            return entry.first
        }
        map.put(key, Pair(ReentrantLock(), AtomicInt(1)))
        return get(key).first
    }
}

private fun <Key> MutableMap<Key, Pair<Lock, Int>>.removeLock(key: Key) {
    synchronzied(lockMapSynchronizder) {
        val entry = get(key) ?: return
        entry.second--
        if (entry.second == 0) {
            remove(key)
        }
    }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively, we can take an alternative approach and not use the key as the synchronizer. we can instead use lock striping: create an array of 8/16/254/whatever entries of Lock. hash the key and grab the lock that matches the key's hash (mod size of array). if you lazy init this array you'll need to sync that too

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does guava do here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell Guava doesn't synchronize loader execution by key, but relies on key hash and Segments to guard concurrent get in general but on a less granular level.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need some sort of reference counting to make this work. you would have:
thread 1 takes lock.
thread 2 gets lock from map and waits
thread 1 finishes and removes key
thread 3 comes in and does not find a lock in map. proceeds with a new lock
thread 2 resumes on old lock

Thanks for this failing case. I'll try to implement the reference counting lock map as suggested and add more tests.

* otherwise associates a new [Lock] with the specified [key] in the map and returns the new lock.
*/
private fun <Key> MutableMap<Key, Lock>.getLock(key: Key): Lock {
val lock: Lock? = get(key) ?: put(key, ReentrantLock())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the very least this would need to be putIfAbsent

Suggested change
val lock: Lock? = get(key) ?: put(key, ReentrantLock())
val lock: Lock? = get(key) ?: putIfAbsent(key, ReentrantLock())

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Android putIfAbsent is only available on API 24+, so I implemented the same logic here by checking get(key) first.

Copy link
Contributor

@eyalgu eyalgu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're really getting close to done with this! Thank you for keeping at it. After taking a deeper look and taking memory issues into account I found 2 more issues that needs to be fixed. I think these are the final 2 issues

@ychescale9 ychescale9 requested a review from eyalgu January 7, 2020 00:16
Copy link
Contributor

@eyalgu eyalgu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I found one more bug. Caching is hard

@ychescale9
Copy link
Contributor Author

Sorry I found one more bug. Caching is hard

I thought I knew what I signed up for when I decided to do this...

@ychescale9 ychescale9 requested a review from eyalgu January 7, 2020 01:05
eyalgu
eyalgu previously approved these changes Jan 7, 2020
Copy link
Contributor

@eyalgu eyalgu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the hard work!

@eyalgu
Copy link
Contributor

eyalgu commented Jan 7, 2020

@yigit I unblocked only needs your ok now

digitalbuddha
digitalbuddha previously approved these changes Jan 7, 2020
@digitalbuddha
Copy link
Contributor

merging tonight unless any qualms

@digitalbuddha
Copy link
Contributor

Could you resolve conflicts and then I'll merge. Thank you

* master:
  fix dokka, update comments, integrate persister (MobileNativeFoundation#56)
  Switch POM packaging format to jar for cache, filesystem and store. (MobileNativeFoundation#54)
  Add an optional param to `Multicaster` to keep the fetches alive even if all downstreams are closed. (MobileNativeFoundation#40)
  Replace Store.fetch() on fresh() methods in ReadMe (MobileNativeFoundation#52)
  Properly implement multicast closing (MobileNativeFoundation#48)

# Conflicts:
#	store/src/main/java/com/dropbox/android/external/store4/Store.kt
#	store/src/main/java/com/dropbox/android/external/store4/StoreBuilder.kt
@ychescale9 ychescale9 dismissed stale reviews from digitalbuddha and eyalgu via c0bbbf2 January 7, 2020 23:51
@ychescale9
Copy link
Contributor Author

ychescale9 commented Jan 8, 2020

Resolved conflicts.

As a result of making the Cache's Key and Value bounded by Any to make sure neither can be null, these changes propagated to the Store and StoreBuilder APIs. I just found out that these have not been updated in DiskReader, DiskWriter and Persister (maybe more). Maybe we can clean up these in a followup (after alpha01 release)?

  • I've also removed the old cache/README.md and I'll write a new one later.
  • Also removed the last remaining java file MultiParserTest.java

@digitalbuddha digitalbuddha merged commit 062deda into MobileNativeFoundation:master Jan 8, 2020
@digitalbuddha
Copy link
Contributor

Great job!! We will be doing our first alpha release today along with a blog post 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[In Progress] Move Cache module to kotlin
6 participants