Home

Awesome

RequestCache

Test codecov Hex version badge

This plug allows us to cache our graphql queries and phoenix controller requests declaritevly

We call the cache inside either a resolver or a controller action and this will store it preventing further executions of our query on repeat requests.

The goal of this plug is to short-circuit any processing phoenix would normally do upon request including json decoding/parsing, the only step that should run is telemetry

Installation

This package can be installed by adding request_cache_plug to your list of dependencies in mix.exs:

def deps do
  [
    {:request_cache_plug, "~> 1.0"}
  ]
end

Documentation can be found at https://hexdocs.pm/request_cache_plug.

Config

This is the default config, it can all be changed but without any configuration setup this will be used:

config :request_cache_plug,
  enabled?: true,
  verbose?: false,
  graphql_paths: ["/graphiql", "/graphql"],
  conn_priv_key: :__shared_request_cache__,
  request_cache_module: RequestCache.ConCacheStore,
  default_ttl: :timer.hours(1),
  default_concache_opts: [
    ttl_check_interval: :timer.seconds(1),
    acquire_lock_timeout: :timer.seconds(1),
    ets_options: [write_concurrency: true, read_concurrency: true]
  ]

Usage

This plug is intended to be inserted into the endpoint.ex fairly early in the pipeline, it should go after telemetry but before our parsers

plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

plug RequestCache.Plug

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"]

We also need to setup a before_send hook to our absinthe_plug (if not using absinthe you can skip this step)

plug Absinthe.Plug, before_send: {RequestCache, :connect_absinthe_context_to_conn}

What this does is allow us to see the results of items we put onto our request context from within plugs coming after absinthe

After that we can utilize our cache in a few ways

Utilization with Phoenix Controllers

def index(conn, params) do
  conn
    |> RequestCache.store(:timer.seconds(60))
    |> put_status(200)
    |> json(%{...})
end

Utilization with Absinthe Resolvers

def all(params, _resolution) do
  # Instead of returning {:ok, value} we return this
  RequestCache.store(value, :timer.seconds(60))
end

Utilization with Absinthe Middleware

field :user, :user do
  arg :id, non_null(:id)

  middleware RequestCache.Middleware, ttl: :timer.seconds(60)

  resolve &Resolvers.User.find/2
end

Specifying Specific Caching Locations

We have a few ways to control the caching location of our RequestCache, by default if you have con_cache installed, we have access to RequestCache.ConCacheStore which is the default setting However we can override this by setting config :request_cache_plug, :request_cache_module, MyCustomCache

Caching module will be expected to have the following API:

def get(key) do
  ...
end

def put(key, ttl, value) do
  ...
end

You are responsible for starting the cache, including ConCacheStore, so if you're planning to use it make sure you add RequestCache.ConCacheStore to the application.ex list of children

We can also override the module for a particular request by passing the option to our graphql middleware or our &RequestCache.store/2 function as [ttl: 123, cache: MyCacheModule]

With Middleware
field :user, :user do
  arg :id, non_null(:id)

  middleware RequestCache.Middleware, ttl: :timer.seconds(60), cache: MyCacheModule

  resolve &Resolvers.User.find/2
end
In a Resolver
def all(params, resolution) do
  RequestCache.store(value, ttl: :timer.seconds(60), cache: MyCacheModule)
end
In a Controller
def index(conn, params) do
  RequestCache.store(conn, ttl: :timer.seconds(60), cache: MyCacheModule)
end

telemetry

Cache events are emitted via :telemetry. Events are:

For GraphQL endpoints it is possible to provide a list of atoms that will be passed through to the event metadata; e.g.:

With Middleware
field :user, :user do
  arg :id, non_null(:id)

  middleware RequestCache.Middleware,
    ttl: :timer.seconds(60),
    cache: MyCacheModule,
    labels: [:service, :endpoint],
    whitelisted_query_names: ["MyQueryName"] # By default all queries are cached, can also whitelist based off query name from GQL Document

  resolve &Resolvers.User.find/2
end
In a Resolver
def all(params, resolution) do
  RequestCache.store(value, ttl: :timer.seconds(60), cache: MyCacheModule, labels: [:service, :endpoint])
end

The events will look like this:

{
  [:request_cache_plug, :graphql, :cache_hit],
  %{count: 1},
  %{ttl: 3600000, cache_key: "/graphql:NNNN", labels: [:service, :endpoint]}
}
Enable Error Caching

In order to enable error caching we can either setup cached_errors in our config or as an option to RequestCache.store or RequestCache.Middleware.

The value of cached_errors can be one of [], :all or a list of reason_atoms as defined by Plug.Conn.Status such as :not_found, or :internal_server_error.

In REST this works off the response codes returned. However, in order to use reason_atoms in GraphQL you will need to make sure your errors contain some sort of %{code: "not_found"} response in them

Take a look at error_message for a compatible error system

Notes/Gotchas

Caching Header

When an item is served from the cache, we return a header rc-cache-status which has a value of HIT. Using this you can tell if the item was served out of cache, without it the item was fetched. We can also invalidate specific items out of the cache, by using the rc-cache-key header which returns the key being used for the cache

Example Reduction

In the case of a large (16mb) payload running through absinthe, this plug cuts down response times from 400+ms -> <400μs

<img width="704" alt="image" src="https://user-images.githubusercontent.com/4650931/161464277-713e994b-1246-43ac-82a1-fb2442cd7bce.png">