Bartek Andrzejczak

Musings on software development

Caching With Variable TTL Using Scalacache

Recently I’ve started using an awesome library - scalacache. It is a functional facade for an underlying cache library of your choice. That way you can fully harness the power of Scala while caching your data. It works great both if you need to cache something manually and if all you need is method call memoization.

My personal case was a tad more complicated, though. What I needed to do was to cache authentication tokens with a time to live (TTL) corresponding to the token’s expiration time. Unfortunately, scalacache API forces you to define the TTL upfront. Fear not, for this is a highly extensible library. It let me define a generic method to do just that, and yet still allowed for flexibility provided by its modes.

1
2
3
4
5
6
7
8
9
10
11
12
def cachingWithTTL[T, M[_]](keyParts: Any*)(value: => M[(T, Duration)])
                           (implicit cache: Cache[T], mode: Mode[M], flags: Flags): M[T] = {
    import mode._
    M.flatMap(get(keyParts:_*)) {
      case Some(found) => M.pure(found)
      case None =>
        M.flatMap(value) {
          case (v, ttl) =>
            M.map(put(keyParts:_*)(v, Some(ttl)))(_ => v)
        }
    }
  }

Let’s start with the signature, as there is a lot happening here. keyParts contain all data that’s going to be used to generate the cache key for both putting data into the cache and retrieving it later on. value is the value that will be inserted into the cache in case of a miss. Of course, it’s not necessarily the same value every time, hence it’s a by-name parameter. Its value is going to be evaluated every time it’s used inside the function’s body. No less important is the type of type of our value parameter - M[(T, Duration)]. Here let’s take a little detour into the world of scalacache modes.

Most APIs are designed to use a single way of returning results - be that wrapped inside a Try[T], Either[L, R], Future[T], or even unwrapped. What if you could decide what monadic wrapper to use for an external library? This is exactly the case with scalacache! The MonadError[T] trait, further extended by the Sync[T] and Async[T] traits, provides a backbone for all possible operations on wrapped input and output types of cache operations (this construct is almost indistinguishable from what’s found in cats-effect). Whenever there’s a need to map some value, there’s no need to provide multiple method implementations for Try, Future and Either. Use mode.M.map from an implicitly provided mode chosen by the end-user of the library. Brilliant, ain’t it?

Going back to the signature, this is exactly what M[(T, Duration)] represents. It is a tuple of the value that is put into cache and a duration representing the TTL of that value in the cache, wrapped into a monad of user’s choice. Going further into the third argument list, we can see a pretty standard scalacache setup of the cache instance, the mode chosen by the user and any caching flags provided, all needed by get and put methods. The return type of M[T] should be self-explanatory by now.

In line 3 the contents of the mode is imported for convenience. Scalacache modes are classes containing field M of type Async[M] which in turn contains all the methods used for mapping the results.

In the next line we go straight into the caching logic. We try to retrieve a value from the cache, saved under the provided key. If the method fails, we cannot go any further, we’ll just return a failure to the user. In turn, the success can be twofold: we can get either a hit or a miss. In case of a hit, we’d like to return it to the user, but first we have to wrap it into a success monad with M.pure function. This would be Success(found) for Try or Right(found) for Either.

The miss is a bit more complicated, as we want to generate a new value, put it into the cache and then return it if the entire process succeeds. We obtain the new value with a by-name call, which of course can fail, but - for our convenience - it is wrapped inside an effects monad. We flatMap on the result of that call and - in case of a success - we’ll get a new value for the cache and a TTL for that value, wrapped inside a (v, ttl) tuple. Now, using the same keyParts we put it into the cache, but that gives us a M[Any] type. We can discard the value inside the effects monad and just replace it with the value we’ve just put into the cache with M.map call. This will give us the expected M[T].

How do we use it? It’s pretty straightforward in the context of the situation given in the introduction:

1
2
3
4
5
6
7
8
9
10
11
import scalacache._

import scala.concurrent.ExecutionContext.Implicits.global
import scalacache.modes.scalaFuture._

implicit val cache: Cache[Token] = ???

def obtainTokenFor(userId: String): Future[(Token, Duration)] = ???

val userId = "123"
cachingWithTTL(userId)(obtainTokenFor(userId)) // yields Future[Token]

Importing scalacache._ will give us a default set of flags. scalacache.modes.scalaFuture._ is a mode that uses Future as an effects monad. It also requires an ExecutionContext imported in the third line. For the remaining implicit value, we need a cache instance to put our objects into. Then, for the two explicit parameters, we need the caching key and a method to generate our values. Works like a charm!

Can you spot a single async-related flaw in that code? What’s your idea to fix it without rewriting the whole thing and still having the same efficiency. Do you think it needs fixing at all? Hope to read your ideas in the comments!

Comments