Current section
Files
Jump to
Current section
Files
lib/isr_plug.ex
defmodule ISRPlug do
@moduledoc """
A generic Plug implementing an Incremental Static Regeneration (ISR) pattern.
This plug allows serving cached data (fresh or stale) quickly while triggering
non-blocking background tasks to refresh expired data. It's designed to be
flexible and reusable in Phoenix applications.
## Configuration
The plug is configured via options passed during the `plug` call in the router:
```elixir
plug ISRPlug,
# --- Required ---
fetch_fun: &YourModule.your_fetch_function/1,
apply_fun: &YourModule.your_apply_function/2,
# --- Optional ---
extract_data_fun: &YourModule.your_extract_function/1,
cache_key_fun: &YourModule.your_cache_key_function/1,
ets_table: :my_specific_isr_cache,
cache_ttl_ms: 60_000, # 1 minute
stale_serving_ttl_ms: 3_600_000, # 1 hour
error_handler_fun: &YourModule.your_error_handler/2
```
See the function documentation for `init/1` for details on each option.
"""
@behaviour Plug
require Logger
@default_ets_table :isr_plug_cache
# 1 minute
@default_cache_ttl_ms 60_000
# 1 hour
@default_stale_serving_ttl_ms 3_600_000
@doc """
Initializes the plug with configuration options.
## Options
* `:fetch_fun` (**required**): A 1-arity function
`(extracted_data :: any()) -> {:ok, value :: any()} | {:error, reason :: any()}`.
Performs the data retrieval.
* `:apply_fun` (**required**): A 2-arity function
`(conn :: Plug.Conn.t(), value :: any()) -> Plug.Conn.t()`.
Applies the successfully retrieved `value` (fresh or stale) to the connection.
Must return a `Plug.Conn.t()`.
* `:extract_data_fun` (*optional*): A 1-arity function
`(conn :: Plug.Conn.t()) -> any()`.
Extracts necessary data from the `conn` to be passed to `:fetch_fun`.
Defaults to `fn _conn -> %{} end`.
* `:cache_key_fun` (*optional*): A 1-arity function
`(conn :: Plug.Conn.t()) -> term()`.
Generates a unique ETS key based on the connection.
Defaults to `fn _conn -> :isr_plug_default_key end`. Ensure this generates
distinct keys if the fetched data varies based on connection properties.
* `:ets_table` (*optional*): Atom name for the ETS table.
Defaults to `#{@default_ets_table}`. Use distinct names if using the plug
multiple times for different purposes.
* `:cache_ttl_ms` (*optional*): Integer milliseconds for how long data is
considered fresh. Defaults to `#{@default_cache_ttl_ms}` (1 minute).
* `:stale_serving_ttl_ms` (*optional*): Integer milliseconds *after* expiry
during which stale data can be served while refreshing.
Defaults to `#{@default_stale_serving_ttl_ms}` (1 hour).
* `:error_handler_fun` (*optional*): A 2-arity function
`(conn :: Plug.Conn.t(), reason :: any()) -> Plug.Conn.t()`.
Called only when a *synchronous* fetch fails. Defaults to a function that
logs the error and returns the original `conn`.
This function also initializes the specified ETS table if it doesn't already exist.
"""
@impl Plug
def init(opts) do
# Ensure required options are present
Keyword.fetch!(opts, :fetch_fun)
Keyword.fetch!(opts, :apply_fun)
# Validate function arities (basic check)
validate_fun!(opts, :fetch_fun, 1, "extracted_data")
validate_fun!(opts, :apply_fun, 2, "(conn, value)")
# Set defaults and validate optional functions
opts = Keyword.put_new(opts, :extract_data_fun, &ISRPlug.default_extract_data/1)
validate_fun!(opts, :extract_data_fun, 1, "conn")
opts = Keyword.put_new(opts, :cache_key_fun, &ISRPlug.default_cache_key/1)
validate_fun!(opts, :cache_key_fun, 1, "conn")
opts = Keyword.put_new(opts, :error_handler_fun, &ISRPlug.default_error_handler/2)
validate_fun!(opts, :error_handler_fun, 2, "(conn, reason)")
opts = Keyword.put_new(opts, :ets_table, @default_ets_table)
opts = Keyword.put_new(opts, :cache_ttl_ms, @default_cache_ttl_ms)
opts = Keyword.put_new(opts, :stale_serving_ttl_ms, @default_stale_serving_ttl_ms)
# Initialize ETS table if it doesn't exist
ets_table = opts[:ets_table]
# Use :named_table property for idempotent creation check
# Check if the table exists by trying to get info; :undefined means it doesn't exist.
unless :ets.info(ets_table, :name) == ets_table do
:ets.new(ets_table, [
:set,
:public,
:named_table,
read_concurrency: true,
write_concurrency: true
])
Logger.info("[#{__MODULE__}] Initialized ETS table: #{inspect(ets_table)}")
end
opts
end
@doc """
Processes the connection according to the ISR logic.
1. Extracts data and determines the cache key using configured functions.
2. Checks the ETS cache.
3. Handles cache hits (fresh or stale).
4. If stale, serves stale data and triggers a background refresh task.
5. Handles cache misses or expired stale TTL by fetching synchronously.
6. If synchronous fetch fails, calls the error handler.
7. If data is available (fresh, stale, or newly fetched), applies it using the configured function.
8. Returns the (potentially modified) connection.
"""
@impl Plug
def call(conn, opts) do
# Extract configuration
extract_data_fun = opts[:extract_data_fun]
cache_key_fun = opts[:cache_key_fun]
ets_table = opts[:ets_table]
# --- Core ISR Logic ---
extracted_data = extract_data_fun.(conn)
cache_key = cache_key_fun.(conn)
log_prefix = "[#{__MODULE__}][#{inspect(cache_key)}]"
# 1. Check Cache
case :ets.lookup(ets_table, cache_key) do
# Cache Hit
[{^cache_key, value, expiry_ts, stale_serve_until_ts}] ->
handle_cache_hit_and_proceed(
conn,
log_prefix,
value,
expiry_ts,
stale_serve_until_ts,
opts,
extracted_data,
cache_key
)
# Cache Miss
[] ->
Logger.debug("#{log_prefix} Cache Miss. Fetching synchronously.")
# Perform initial synchronous fetch
handle_sync_fetch_and_proceed(conn, log_prefix, opts, extracted_data, cache_key)
end
end
# --- Default Implementation Functions (Need to be public to compile) ---
@doc """
Default function for extracting data. Returns an empty map. Must be public.
"""
def default_extract_data(_conn), do: %{}
@doc """
Default function for generating a cache key. Returns a fixed atom. Must be public.
"""
def default_cache_key(_conn), do: :isr_plug_default_key
@doc """
Default error handler if synchronous fetch fails. Must be public.
"""
def default_error_handler(conn, reason) do
log_prefix = "[#{__MODULE__}][DefaultErrorHandler]"
Logger.error(
"#{log_prefix} Synchronous fetch failed: #{inspect(reason)}. Passing connection through unchanged."
)
conn
end
# --- Private Processing Logic ---
# Handles the logic when an item is found in the cache
defp handle_cache_hit_and_proceed(
conn,
log_prefix,
value,
expiry_ts,
stale_serve_until_ts,
opts,
extracted_data,
cache_key
) do
now = System.monotonic_time()
apply_fun = opts[:apply_fun]
cond do
# Fresh Hit
now < expiry_ts ->
Logger.debug("#{log_prefix} Cache Hit (Fresh)")
apply_value(conn, value, apply_fun)
# Stale Hit (within stale serving window)
now < stale_serve_until_ts ->
Logger.debug("#{log_prefix} Cache Hit (Stale). Serving stale, triggering refresh.")
# Serve stale value immediately
conn_with_stale = apply_value(conn, value, apply_fun)
# Trigger background refresh
# Explicitly pass necessary opts to the background task
ets_table = opts[:ets_table]
fetch_fun = opts[:fetch_fun]
cache_ttl_ms = opts[:cache_ttl_ms]
stale_ttl_ms = opts[:stale_serving_ttl_ms]
Task.start(fn ->
perform_background_refresh(
log_prefix,
ets_table,
fetch_fun,
cache_ttl_ms,
stale_ttl_ms,
extracted_data,
cache_key
)
end)
conn_with_stale
# Stale Hit (expired stale TTL)
true ->
Logger.warning("#{log_prefix} Cache Hit (Expired Stale TTL). Fetching synchronously.")
# Treat as cache miss, fetch synchronously
handle_sync_fetch_and_proceed(conn, log_prefix, opts, extracted_data, cache_key)
end
end
# Handles the logic for synchronous fetching (cache miss or expired stale TTL)
defp handle_sync_fetch_and_proceed(conn, log_prefix, opts, extracted_data, cache_key) do
ets_table = opts[:ets_table]
fetch_fun = opts[:fetch_fun]
cache_ttl_ms = opts[:cache_ttl_ms]
stale_ttl_ms = opts[:stale_serving_ttl_ms]
error_handler_fun = opts[:error_handler_fun]
apply_fun = opts[:apply_fun]
case fetch_synchronously_and_cache(
log_prefix,
ets_table,
cache_key,
fetch_fun,
extracted_data,
cache_ttl_ms,
stale_ttl_ms
) do
{:ok, value} ->
# Fetched successfully, apply the new value
apply_value(conn, value, apply_fun)
{:error, reason} ->
# Sync fetch failed, call the error handler
Logger.error("#{log_prefix} Synchronous fetch failed: #{inspect(reason)}")
error_handler_fun.(conn, reason)
end
end
# Applies the value using the configured apply_fun
defp apply_value(conn, value, apply_fun) do
if not is_nil(value) do
apply_fun.(conn, value)
else
Logger.warning(
"[#{__MODULE__}] No value available to apply (sync fetch likely failed and error handler didn't halt). Passing conn through."
)
conn
end
end
# --- Private Fetching & Caching Helpers ---
defp fetch_synchronously_and_cache(
log_prefix,
ets_table,
cache_key,
fetch_fun,
extracted_data,
cache_ttl_ms,
stale_ttl_ms
) do
Logger.debug("#{log_prefix} Fetching synchronously...")
case fetch_dynamic_value(log_prefix, fetch_fun, extracted_data) do
{:ok, value} ->
update_cache(log_prefix, ets_table, cache_key, value, cache_ttl_ms, stale_ttl_ms)
{:ok, value}
{:error, reason} ->
{:error, reason}
end
end
defp perform_background_refresh(
log_prefix,
ets_table,
fetch_fun,
cache_ttl_ms,
stale_ttl_ms,
extracted_data,
cache_key
) do
Logger.debug("#{log_prefix} Performing background refresh...")
case fetch_dynamic_value(log_prefix, fetch_fun, extracted_data) do
{:ok, value} ->
update_cache(log_prefix, ets_table, cache_key, value, cache_ttl_ms, stale_ttl_ms)
Logger.debug("#{log_prefix} Background refresh successful.")
{:error, reason} ->
Logger.error("#{log_prefix} Background refresh failed: #{inspect(reason)}")
end
:ok
end
defp fetch_dynamic_value(log_prefix, fetch_fun, extracted_data) do
try do
case fetch_fun.(extracted_data) do
{:ok, value} ->
{:ok, value}
{:error, reason} ->
{:error, reason}
other ->
Logger.warning(
"#{log_prefix} Fetch function returned unexpected value: #{inspect(other)}. Expected {:ok, value} or {:error, reason}. Treating as error."
)
{:error, {:unexpected_return, other}}
end
rescue
e ->
err = Exception.format(:error, e, __STACKTRACE__)
Logger.error("#{log_prefix} Unhandled error during fetch_fun execution: #{err}")
{:error, {:exception, err}}
end
end
defp update_cache(log_prefix, ets_table, cache_key, value, cache_ttl_ms, stale_ttl_ms) do
now = System.monotonic_time()
expiry_ts = now + System.convert_time_unit(cache_ttl_ms, :millisecond, :native)
stale_serve_until_ts =
expiry_ts + System.convert_time_unit(stale_ttl_ms, :millisecond, :native)
:ets.insert(ets_table, {cache_key, value, expiry_ts, stale_serve_until_ts})
Logger.debug("#{log_prefix} Updated cache.")
end
# Helper for init options validation
defp validate_fun!(opts, key, arity, signature) do
fun = Keyword.get(opts, key)
if fun && !is_function(fun, arity) do
raise ArgumentError, "Option :#{key} must be a function with arity #{arity} #{signature}"
end
end
end