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

Updates cache api to make getOrElseUpdate methods atomic #9615

Merged
merged 20 commits into from Oct 15, 2019

Conversation

gurkankaymak
Copy link
Contributor

fixes #8605

Hi, updated cache api to make getOrElseUpdate methods atomic

I don't know why AsyncCacheApi does not use the AsyncCache of Caffeine, that way AsyncCacheApi.getOrElseUpdate could lazily evaluate orElse part.

I'm not sure if the current implementation of AsyncCacheApi.getOrElseUpdate is acceptable, I couldn't find a way to make it atomic and evaulate orElse lazily with the sync api. Maybe just for this method the previous way could more preferable (get-if not found-put instead of atomic)

I could update the pr according to your comments/recommendations

@gurkankaymak gurkankaymak reopened this Sep 2, 2019
@gurkankaymak gurkankaymak changed the title fixes #8605 Updates cache api to make getOrElseUpdate methods atomic Sep 3, 2019
case Some(value) => Future.successful(value)
case None => orElse.flatMap(value => set(key, value, expiration).map(_ => value))
}
orElse.map(orElseValue => sync.getOrElseUpdate(key, expiration)(orElseValue))

Choose a reason for hiding this comment

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

Does this always compute a new future and maybe add it to the cache?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

actually it should be computed and put if there is no value with the given key

Choose a reason for hiding this comment

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

It just reads to me, not having written scala in many years, that it always computes a new future value. I think the intent is to only compute if absent. Could this class use the AsyncCache in caffeine instead of the synchronous one?

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 asked the same question in pr description, that bothered me as well 😃

I don't know why AsyncCacheApi does not use the AsyncCache of Caffeine, that way AsyncCacheApi.getOrElseUpdate could lazily evaluate orElse part

I couldn't find a way to make it atomic and evaulate orElse lazily with the sync api.

Copy link
Member

Choose a reason for hiding this comment

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

I don't know why AsyncCacheApi does not use the AsyncCache of Caffeine, that way AsyncCacheApi.getOrElseUpdate could lazily evaluate orElse part

I would say the main reason is that then access from both async and sync cache can keep cached data consistent. It is also more convenient to not implement them both from scratch so it is less code to maintain, test, etc.

But I suspect we can implement play.cache.caffeine.NamedCaffeineCache in terms of com.github.benmanes.caffeine.cache.AsyncCache instead and have some sync methods that will just wrap values using CompletableFuture.completedFuture. Another possible constraint is if EhCache has a way to do that.

Choose a reason for hiding this comment

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

Note that AsyncCache has a synchronous() method to provide a Cache view. The async cache internally maintains K=>Future<V> and the synchronous view tries to provide a K=>V view by blocking on the future as appropriate. That may be useful in simplifying the code.

Copy link
Member

Choose a reason for hiding this comment

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

That makes sense to me.

It would be better to open a new PR though, so it will be focused on this other change and easier to review.

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 open a new pr for using com.github.benmanes.caffeine.cache.AsyncCache and update on this pr on top of that, thanks @ben-manes, @marcospereira

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hi @marcospereira, I created a pr for this #9623

currentDuration
}

def expireAfterRead(key: String, value: ExpirableCacheValue[Any], currentTime: Long, currentDuration: Long): Long = {
Copy link
Member

Choose a reason for hiding this comment

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

If I understand correctly, this will reset the duration for every read, which is not the existing behavior for policy().expireVariably().get().put(...). Is that correct, @ben-manes?

Choose a reason for hiding this comment

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

This is called on every read, however this implementation returns currentDuration. This indicates that no change to the entry's lifetime should occur due to this read, and the cache will not modify the lifetime.

It looks like this configuration is that a lifetime is set by its creation time, and not modified thereafter. The set(k, v, duration) may modify the entry's lifetime on an update. So I believe that the expireAfterUpdate may need similar logic to expireAfterCreate.

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're right policy().expireVariably().get().put does not use CaffeineDefaultExpiry, it uses a custom Expiry that behaves same on expireAfterCreate and expireAfterUpdate methods

cache.policy().expireVariably().get().put(key, value, Long.MaxValue, TimeUnit.DAYS)
case finite: FiniteDuration =>
val seconds = finite.toSeconds
if (seconds <= 0) {
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we are handling this case where the duration is negative anymore. Ideally, if there is no test for it, we should add one to ensure we don't have a regression here.

case Some(value) => Future.successful(value)
case None => orElse.flatMap(value => set(key, value, expiration).map(_ => value))
}
orElse.map(orElseValue => sync.getOrElseUpdate(key, expiration)(orElseValue))
Copy link
Member

Choose a reason for hiding this comment

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

I don't know why AsyncCacheApi does not use the AsyncCache of Caffeine, that way AsyncCacheApi.getOrElseUpdate could lazily evaluate orElse part

I would say the main reason is that then access from both async and sync cache can keep cached data consistent. It is also more convenient to not implement them both from scratch so it is less code to maintain, test, etc.

But I suspect we can implement play.cache.caffeine.NamedCaffeineCache in terms of com.github.benmanes.caffeine.cache.AsyncCache instead and have some sync methods that will just wrap values using CompletableFuture.completedFuture. Another possible constraint is if EhCache has a way to do that.

gurkankaymak added a commit to gurkankaymak/playframework that referenced this pull request Sep 10, 2019
gurkankaymak added a commit to gurkankaymak/playframework that referenced this pull request Sep 14, 2019
gurkankaymak added a commit to gurkankaymak/playframework that referenced this pull request Oct 1, 2019
updated cache apis to make getOrElseUpdate methods atomic
…efaultExpiry.java for this reason

"class play.cache.caffeine.CaffeineDefaultExpiry does not have a correspondent in current version"
@mkurz
Copy link
Member

mkurz commented Oct 2, 2019

@gurkankaymak I saw you pushed some commits here. Is there still something missing or is this pull request ready for another review?

@gurkankaymak
Copy link
Contributor Author

@gurkankaymak I saw you pushed some commits here. Is there still something missing or is this pull request ready for another review?

@mkurz just pushed a new commit in the EhCache part, pr could be reviewed now

Copy link
Member

@marcospereira marcospereira left a comment

Choose a reason for hiding this comment

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

Thanks, @gurkankaymak.

I think it will also be useful to add some details about this change in our migration guide. For example, when using EhCache, the orElse part is now eargerly computed even when the value is present. This may be surprising to users, and since we plan to move EhCache support out of Play, I would say that it would be better to not bother changing it (the migration section can then focus exclusively on Caffeine implementation which is the default one).

WDYT?

- made ExpirableCacheValue.scala and DefaultCaffeineExpiry.scala package private
@gurkankaymak
Copy link
Contributor Author

Thanks, @gurkankaymak.

I think it will also be useful to add some details about this change in our migration guide. For example, when using EhCache, the orElse part is now eargerly computed even when the value is present. This may be surprising to users, and since we plan to move EhCache support out of Play, I would say that it would be better to not bother changing it (the migration section can then focus exclusively on Caffeine implementation which is the default one).

WDYT?

other than eagerly evaluating the orElse part in EhCache there aren't much things that affect api users, I can think of the following things that can be mentioned in the migration guide, please let me know if any additions/suggestions to these

  • eagerly evaluating the orElse in EhCache
  • depricating CaffeineDefaultExpiry
  • making getOrElseUpdate methods atomic

updated Cache APIs to make getOrElseUpdate methods atomic
@ben-manes
Copy link

I would suggest leaving the ehcache version non-atomic as always evaluating defeats the purpose of a cache. Instead it seems like an incentive to migrate. There are probably other implementations, like redis/memcached, which would not be atomic either.

@marcospereira
Copy link
Member

marcospereira commented Oct 8, 2019

other than eagerly evaluating the orElse part in EhCache there aren't much things that affect api users, I can think of the following things that can be mentioned in the migration guide, please let me know if any additions/suggestions to these

  • eagerly evaluating the orElse in EhCache
  • deprecating CaffeineDefaultExpiry
  • making getOrElseUpdate methods atomic

It looks like to me that it would be better not to touch the EhCache implementation, but instead only the Caffeine one and make it clear at the migration guide that EhCache's getOrElseUpdate is still not atomic. So, the changes that make sense to me are:

  • Undo EhCache changes
  • deprecating CaffeineDefaultExpiry
  • making getOrElseUpdate methods atomic

And document these on the migration guide. Does that make sense to you?

Best.

@gurkankaymak
Copy link
Contributor Author

I would suggest leaving the ehcache version non-atomic as always evaluating defeats the purpose of a cache. Instead it seems like an incentive to migrate. There are probably other implementations, like redis/memcached, which would not be atomic either.

I see your point, orElse part could do db/api call or some other expensive calculation, this would defeat the purpose of the cache

Copy link
Member

@marcospereira marcospereira left a comment

Choose a reason for hiding this comment

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

Some minor comments, but I think we are almost there. Thanks for your patience, @gurkankaymak. Things to do before merging this:

  1. Squashing commits to organize them around the final changes better
  2. Add tests to ensure orElse is NOT computed when the cache (sync and async) has the value

Besides that, it is looking good to me.

Copy link

@ben-manes ben-manes left a comment

Choose a reason for hiding this comment

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

lgtm, for what that's worth!

Copy link
Member

@marcospereira marcospereira left a comment

Choose a reason for hiding this comment

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

LGTM. Thank you, @gurkankaymak!

@mergify mergify bot merged commit e7fb86f into playframework:master Oct 15, 2019
@dwijnand dwijnand added this to the Play 2.8.0-M6 milestone Oct 15, 2019

private def calculateExpirationTime(durationMaybe: Option[Duration]): Long = {
durationMaybe match {
case Some(duration) if duration.isFinite && duration.lteq(0.second) => 1.second.toNanos
Copy link
Member

Choose a reason for hiding this comment

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

@gurkankaymak it's been a while, but do you remember why you gave the element 1 second to live? My guess was you did that because back then the ehcache implementation set the time to life for an element to 1 second as well if expiration was actually set to 0 seconds.
See this comment: https://github.com/playframework/playframework/pull/5923/files#r1005645691

Or was there another reason for doing that? Because I will set that to 0 in #11516 as I can see no sense in it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mkurz I don't think there was a specific reason for it, I agree with your guess, probably I was preserving the behaviour at the time while making the methods atomic

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.

Cache API getOrElseUpdate should be atomic.
5 participants