defmodule Spoxy.Cache do @moduledoc """ responsible on orchestration of the request lifecycle. in case the request is cached returns the response, if the cached data is in the cache but with stale data, the cached data will be returned when executed within `non-blocking` mode. when the request isn't in the cache, trigger a calculation (called prerender) and stores the result for later usage. """ alias Spoxy.Cache.Janitor def async_get_or_fetch(mods, req, req_key, opts \\ []) do Task.async(fn -> started = System.system_time(:milliseconds) {:ok, resp} = get_or_fetch(mods, req, req_key, opts) ended = System.system_time(:milliseconds) {:ok, resp, ended - started} end) end def get_or_fetch(mods, req, req_key, opts \\ []) do {prerender_module, store_module, tasks_executor_mod} = mods hit_or_miss = get(store_module, req_key, opts) case hit_or_miss do {:hit, {resp, metadata}} -> if should_invalidate?(req, resp, metadata) do # the cache holds stale data, # now we need to decide if we will force refreshing the cache # before returning a response (a _blocking_ call) # or whether we'll return a stale reponse and enqueue # a background task that will refresh the cache blocking = Keyword.get(opts, :blocking, false) if blocking do # we don't want the stale data, so force recalculation refresh_req!({prerender_module, store_module}, req, req_key, opts) else # we'll spawn a background task in a fire-and-forget manner # that will make sure the stale data is refreshed # so that future requests, will benefit from a fresh data enqueue_req(tasks_executor_mod, req_key, req, opts) # returning the stale data {:ok, resp} end else # we have a fresh data in the cache {:ok, resp} end {:miss, _} -> # we have nothing in the cache, we need to calculate the request's value refresh_req!({prerender_module, store_module}, req, req_key, opts) end end def get_and_trigger_async_fetch(mods, req, req_key, opts \\ []) do {_prerender_module, store_module, tasks_executor_mod} = mods hit_or_miss = get(store_module, req_key, opts) case hit_or_miss do {:hit, {resp, metadata}} -> if should_invalidate?(req, resp, metadata) do # we have stale data in the cache... # we trigger a refresh in the background enqueue_req(tasks_executor_mod, req_key, req, opts) {:ok, resp} else # we have a fresh data {:ok, resp} end {:miss, _} -> enqueue_req(tasks_executor_mod, req_key, req, opts) {:miss, "couldn't locate in cache"} end end def get(store_module, req_key, opts \\ []) do {:ok, table_name} = Keyword.fetch(opts, :table_name) lookup = lookup_req(store_module, table_name, req_key) case lookup do nil -> {:miss, "couldn't locate in cache"} _ -> {:hit, lookup} end end def refresh_req!({prerender_module, store_module}, req, req_key, opts) do case do_req(prerender_module, req) do {{:ok, resp}, :active} -> {:ok, table_name} = Keyword.fetch(opts, :table_name) {:ok, ttl_ms} = Keyword.fetch(opts, :ttl_ms) version = UUID.uuid1() metadata = %{version: version} store_opts = [table_name, {req, req_key, resp, metadata}, opts] store_req!(store_module, store_opts) do_janitor_work = Keyword.get(opts, :do_janitor_work, true) if do_janitor_work do Janitor.schedule_janitor_work( store_module, table_name, req_key, version, ttl_ms * 2 ) end {:ok, resp} {{:ok, resp}, :passive} -> {:ok, resp} {{:error, reason}, _} -> {:error, reason} end end def do_req(prerender_module, req) do apply(prerender_module, :perform, [req]) end def store_req!(store_module, opts) do apply(store_module, :store_req!, opts) end def lookup_req(store_module, table_name, req_key) do apply(store_module, :lookup_req, [table_name, req_key]) end def should_invalidate?(_req, _resp, metadata) do %{expires_at: expires_at} = metadata System.system_time(:milliseconds) > expires_at end def enqueue_req(tasks_executor_mod, req_key, req, opts) do Task.start(fn -> tasks_executor_mod.enqueue_task(req_key, [req, opts]) end) end end