/ scala

Caching with Akka HTTP

In the previous article, we discussed creating a RESTful application using Akka HTTP. As I deep dive more and more into the Akka HTTP and Scala ecosystem, the simplicity, and power the ecosystem brings in amaze me more and more. In this article, we are going to explore Akka HTTP support for caching. With the power of Scala DSL and Akka HTTP directives, it is really fun and manageable to enable caching on Akka HTTP.

Caching in Akka HTTP can be done in a number of ways. The primary use case is to wrap a heavy-weight expensive operation with a caching layer using a certain type of key K. The key K is later used for idiomatically getting the cached responses for any successive operation.

Akka HTTP Caching uses Java 8 Caching library Caffeine that supports time-based and frequency based eviction, the default being the Least frequently used (LFU) strategy.

Setting up Caching support

Akka Caching is supported as an independent module which needs to be imported into the project. This can be be done by the following sbt snippet.

libraryDependencies += "com.typesafe.akka" %% "akka-http-caching" % "10.1.2"

Pre-setup

Let's start with a very simple use case of caching the GET requests. We will be going to use the APIs we created in the previous article.. The HTTP GET endpoint looked something like this

GET http://localhost:8080/api/employee/{id}

The Akka HTTP rest endpoint was defined as follows.

get {
      path(Segment) { id =>
        onComplete(getEmployeeById(id)) {
          _ match {
            case Success(employee) =>
              logger.info(s"Got the employee records given the employee id ${id}")
              complete(StatusCodes.OK, employee)
            case Failure(throwable) =>
              logger.error(s"Failed to get the employee record given the employee id ${id}")
              throwable match {
                case e: EmployeeNotFoundException => complete(StatusCodes.NotFound, "No employee found")
                case e: DubiousEmployeeRecordsException => complete(StatusCodes.NotFound, "Dubious records found.")
                case _ => complete(StatusCodes.InternalServerError, "Failed to get the employees.")
              }
          }
        }
      }
    }

In the snippet above, we used Akka HTTP Directives to define a nested route, which uses getEmployeeById method to fetch the Employee details. Since, the method returns a non-blocking Future reference, we use onComplete Directive to asynchrnously handle the response.

Adding Caching

To add caching to the above GET endpoint, we can make use of alwaysCache directive and add the directive to the root of the cacheable route as below.

get {
      path(Segment) { id =>
          alwaysCache(myCache, simpleKeyer) {
            onComplete(getEmployeeById(id)) {
              _ match {
                case Success(employee) =>
                  logger.info(s"Got the employee records given the employee id ${id}")
                  complete(StatusCodes.OK, employee)
                case Failure(throwable) =>
                  logger.error(s"Failed to get the employee record given the employee id ${id}")
                  throwable match {
                    case e: EmployeeNotFoundException => complete(StatusCodes.NotFound, "No employee found")
                    case e: DubiousEmployeeRecordsException => complete(StatusCodes.NotFound, "Dubious records found.")
                    case _ => complete(StatusCodes.InternalServerError, "Failed to get the employees.")
                  }
              }
            }
         }
      }
    }

In the above route definition, the alwaysCache directive expects a cache container and a keyer partial function. A cache container uses the Caffeine API behind the scene to create a caching entity to store and retrieve the cached objects. It expects a key type to store and retrieve cached entries. In the above case, since we are using GET endpoints, they should be idempotent and we can make use of request Uri as the key.

import akka.http.scaladsl.server.directives.CachingDirectives._
lazy val myCache = routeCache[Uri]

The alwaysCache directive also expects a partial function to map the request context to the cacheable keys to be able to retrieve entries from the cache. Since we are using Uri as the cache keys, the partial function could look something like this.

lazy val simpleKeyer: PartialFunction[RequestContext, Uri] = {
    case r: RequestContext if r.request.method == HttpMethods.GET => r.request.uri
  }

Adding Caching to POST request

Typically, POST requests are not idempotent and are meant for adding content to the server. But, it's not always true. In the example article, we have created a POST request for the search API which is as follows.

POST http://localhost:8080/api/employee/query
{
	"id": "106",
	"firstName": "Manila",
	"lastName": "Winston"
}

To enable caching for the above POST request, we need to identify the key for the cached entries. Since Uri won't suffice for POST requests, we need to create keys using the POST payload. Following snippets highlights the keyer object and cache object.

def complexKeyer(payload: String): PartialFunction[RequestContext, String] = {
    case r: RequestContext if r.request.method == HttpMethods.POST => s"${r.request.uri}_${payload}"
  }
  
lazy val myComplexCache = routeCache[String]

For simplicity, we have created the keyer as a function returning the PartialFunction in order to accept the POST payload and append it to the cache key entries. The route looks like below.

post {
      pathPrefix("query") {
        entity(as[QueryEmployee]) { q =>
          path("cached") {
            alwaysCache(myComplexCache, complexKeyer(q.toJson.compactPrint)) {
              onComplete(queryEmployee(q.id, q.firstName, q.lastName)) {
                _ match {
                  case Success(employees) =>
                    logger.info("Got the employee records for the search query.")
                    complete(StatusCodes.OK, employees)
                  case Failure(throwable) =>
                    logger.error("Failed to get the employees with the given query condition.")
                    complete(StatusCodes.InternalServerError, "Failed to query the employees.")
                }
              }
            }
          } 
       }
   }

Summing it up

In this article, we have used existing REST endpoints and created new endpoints that use Akka-Caching to enable cached responses for the endpoints. We have used alwaysCache directive to force caching all the request, irrespective of the Cache-Control header. In the later articles, we will explore other options. Here's some example of the resulting API.

Original GET Request

Get-Original

Cached GET Request

GET-Cached

Original POST Request

Original-POST

Cached POST Request

POST-Cached

The complete source code is available here.. In the later articles, we will take a look at alternative caching strategies and cache configuration.

Love Hasija

Love Hasija

Full Stack Research Engineer, Software Architect | Helped build next generation software systems | Distributed Systems Fanatic | Open Source Hacker.

Read More
Caching with Akka HTTP
Share this