Current section
Files
Jump to
Current section
Files
lib/isr_plug.ex
defmodule ISRPlug do
# --- Defaults ---
@default_ets_table :isr_plug_cache
# 1 minute
@default_cache_ttl_ms 60_000
# 1 hour
@default_stale_serving_ttl_ms 3_600_000
@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.
## Setup
1. **Add Cache Manager to Supervisor:** Ensure the ETS cache table exists
when your application starts by adding `ISRPlug.CacheManager` to your
application's supervision tree (`lib/my_app/application.ex`):
```elixir
# lib/my_app/application.ex
def start(_type, _args) do
children = [
# ... other children
MyApp.Repo,
MyAppWeb.Endpoint,
# Add the cache manager. Creates :isr_plug_cache by default.
ISRPlug.CacheManager
# OR: Specify custom/multiple tables if needed
# {ISRPlug.CacheManager, table_names: [:my_isr_cache, :another_cache]}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
```
You need to create the `lib/isr_plug/cache_manager.ex` file (provided in the previous explanation)
or ensure it's part of the dependency package if this Plug is distributed.
2. **Use the Plug:** Add `plug ISRPlug` in your router pipeline, configuring
it with your functions and ensuring the `:ets_table` option matches a
table name managed by `ISRPlug.CacheManager`.
```elixir
# In your router.ex
pipeline :isr_protected do
plug :accepts, ["html"]
# ... other plugs
plug ISRPlug,
fetch_fun: &MyData.fetch_live_data/1,
apply_fun: &MyPageController.apply_data_to_conn/2,
cache_key_fun: &MyPageController.generate_cache_key/1,
ets_table: :isr_plug_cache # Must match a table from CacheManager
# ... other options like cache_ttl_ms, etc.
end
```
## Configuration Options (for the `plug` call)
The plug is configured via options passed during the `plug` call in the router:
* `: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}`. Must match a name managed by `ISRPlug.CacheManager`.
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 (cache miss or expired stale TTL).
Defaults to a function that logs the error and returns the original `conn`.
"""
@behaviour Plug
require Logger
@doc """
Returns the default ETS table name used by ISRPlug.
Useful for configuring the default `ISRPlug.CacheManager`.
"""
@spec default_ets_table() :: atom()
def default_ets_table, do: @default_ets_table
@doc """
Initializes the plug configuration options.
NOTE: This function **no longer creates the ETS table**. Ensure the table
is created at application startup by adding `ISRPlug.CacheManager`
(or your own manager) to your application supervisor.
This function validates the provided options and sets defaults.
"""
@impl Plug
def init(opts) do
# --- Option validation and defaulting ---
Keyword.fetch!(opts, :fetch_fun)
Keyword.fetch!(opts, :apply_fun)
validate_fun!(opts, :fetch_fun, 1, "extracted_data")
validate_fun!(opts, :apply_fun, 2, "(conn, value)")
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)
# --- End option validation ---
# Return the validated/defaulted options
opts
end
@doc """
Processes the connection according to the ISR logic.
Assumes the ETS table specified in the `:ets_table` option (passed during `init/1`)
already exists, managed externally (e.g., by `ISRPlug.CacheManager`).
"""
@impl Plug
def call(conn, opts) do
# Extract configuration needed for this request
extract_data_fun = opts[:extract_data_fun]
cache_key_fun = opts[:cache_key_fun]
# Reads table name from opts passed by init
ets_table = opts[:ets_table]
# --- Core ISR Logic ---
extracted_data = extract_data_fun.(conn)
cache_key = cache_key_fun.(conn)
# Include table name in log prefix for clarity when using multiple tables
log_prefix = "[#{__MODULE__}][#{inspect(ets_table)}][#{inspect(cache_key)}]"
# 1. Check Cache
# This lookup expects the table to exist because CacheManager created it
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 (Public visibility required for Plug init) ---
@doc """
Default function for extracting data. Returns an empty map.
"""
def default_extract_data(_conn), do: %{}
@doc """
Default function for generating a cache key. Returns a fixed atom.
"""
def default_cache_key(_conn), do: :isr_plug_default_key
@doc """
Default error handler if synchronous fetch fails. Logs and passes conn through.
"""
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
# Pass necessary opts explicitly 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
# Extract necessary options
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 configured 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, handling nil values
defp apply_value(conn, value, apply_fun) do
if not is_nil(value) do
apply_fun.(conn, value)
else
# This case might happen if fetch failed and error handler didn't halt/change conn
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 ---
# Performs a synchronous fetch and updates the cache on success
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 already logged by fetch_dynamic_value if needed
{:error, reason}
end
end
# Performs an asynchronous fetch and updates the cache (called via Task.start)
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} ->
# Log the error here as the caller task won't report it directly
Logger.error("#{log_prefix} Background refresh failed: #{inspect(reason)}")
# Optionally: Implement retry or backoff logic here
end
# Task completes
:ok
end
# Safely executes the user-provided fetch_fun
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} ->
# Propagate known fetch errors
{:error, reason}
other ->
# Handle cases where fetch_fun doesn't return the expected tuple
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
# Catch any exceptions during fetch_fun execution
e ->
err = Exception.format(:error, e, __STACKTRACE__)
Logger.error("#{log_prefix} Unhandled exception during fetch_fun execution: #{err}")
# Return exception as error reason
{:error, {:exception, e}}
end
end
# Updates the ETS cache with the new value and calculated timestamps
defp update_cache(log_prefix, ets_table, cache_key, value, cache_ttl_ms, stale_ttl_ms) do
now = System.monotonic_time()
# Calculate expiry timestamp based on monotonic time + TTL
expiry_ts = now + System.convert_time_unit(cache_ttl_ms, :millisecond, :native)
# Calculate when stale serving should stop (expiry + stale TTL)
stale_serve_until_ts =
expiry_ts + System.convert_time_unit(stale_ttl_ms, :millisecond, :native)
# Insert/update the cache entry
:ets.insert(ets_table, {cache_key, value, expiry_ts, stale_serve_until_ts})
Logger.debug("#{log_prefix} Updated cache.")
end
# --- Private Validation Helper ---
# Helper for init options validation (ensures functions have correct arity)
defp validate_fun!(opts, key, arity, signature) do
fun = Keyword.get(opts, key)
# Ensure the key exists and holds a function of the specified arity
unless is_function(fun, arity) do
# Raise a more informative error if the key is missing or not a function
cond do
is_nil(fun) ->
raise ArgumentError, "Required option :#{key} is missing."
not is_function(fun) ->
raise ArgumentError, "Option :#{key} (value: #{inspect(fun)}) must be a function."
true ->
# Arity mismatch
raise ArgumentError,
"Option :#{key} must be a function with arity #{arity} #{signature}, got function with arity #{Function.info(fun)[:arity]}."
end
end
end
end