defmodule Image.Plug.VariantStore.ETS.Server do
@moduledoc false
# GenServer that owns the ETS table backing
# `Image.Plug.VariantStore.ETS`.
#
# The default table name is `:image_plug_variants`. The server
# runs as a singleton under `Image.Plug.Application`. Hosts that
# want isolated tables (e.g. tests) can start additional instances
# under their own supervisor with `:name` and `:table` options.
use GenServer
alias Image.Plug.{Pipeline, Variant}
@default_table :image_plug_variants
@typedoc false
@type option ::
{:name, GenServer.name()}
| {:table, atom()}
| {:seed, [{String.t(), seed_value()}]}
@typedoc false
@type seed_value ::
Pipeline.t()
| Variant.t()
| {provider :: module(), options_string :: String.t()}
| {provider :: module(), options_string :: String.t(), variant_options :: keyword()}
@doc false
@spec default_table() :: atom()
def default_table, do: @default_table
@doc false
def start_link(options) do
name = Keyword.get(options, :name, __MODULE__)
GenServer.start_link(__MODULE__, options, name: name)
end
@doc false
def child_spec(options) do
%{
id: Keyword.get(options, :name, __MODULE__),
start: {__MODULE__, :start_link, [options]},
type: :worker,
restart: :permanent,
shutdown: 5_000
}
end
@impl GenServer
def init(options) do
table = Keyword.get(options, :table, @default_table)
persistence = normalise_persistence(Keyword.get(options, :persistence), table)
:ets.new(table, [
:set,
:protected,
:named_table,
read_concurrency: true
])
seed_public(table)
hydrate_from_persistence(table, persistence)
seeds = Keyword.get(options, :seed, [])
Enum.each(seeds, fn {name, value} -> do_seed(table, name, value) end)
{:ok, %{table: table, persistence: persistence}}
end
@impl GenServer
def handle_call({:put, %Variant{} = variant}, _from, state) do
now = DateTime.utc_now()
stored =
case :ets.lookup(state.table, variant.name) do
[{_, %Variant{inserted_at: inserted_at}}] when not is_nil(inserted_at) ->
%{variant | inserted_at: inserted_at, updated_at: now}
_ ->
%{variant | inserted_at: now, updated_at: now}
end
:ets.insert(state.table, {stored.name, stored})
persist(state, :put, stored.name, stored)
{:reply, {:ok, stored}, state}
end
def handle_call({:delete, name}, _from, state) do
case :ets.take(state.table, name) do
[_] ->
persist(state, :delete, name, nil)
{:reply, :ok, state}
[] ->
{:reply, {:error, :not_found}, state}
end
end
# ---------- persistence helpers ----------
defp normalise_persistence(nil, _table), do: nil
defp normalise_persistence({module, opts}, table) when is_atom(module) and is_list(opts) do
{module, Keyword.put_new(opts, :table, table)}
end
defp normalise_persistence(module, table) when is_atom(module) do
{module, [table: table]}
end
defp hydrate_from_persistence(_table, nil), do: :ok
defp hydrate_from_persistence(table, {module, opts}) do
case module.load(opts) do
{:ok, variants} when is_list(variants) ->
Enum.each(variants, fn %Variant{name: name} = variant ->
:ets.insert(table, {name, variant})
end)
{:error, reason} ->
require Logger
Logger.warning(
"image_plug: variant persistence #{inspect(module)} failed to load: " <>
"#{inspect(reason)}. Starting with no persisted variants."
)
end
end
defp persist(%{persistence: nil}, _action, _name, _variant), do: :ok
defp persist(%{persistence: {module, opts}}, action, name, variant) do
case module.write(action, name, variant, opts) do
:ok ->
:ok
{:error, reason} ->
require Logger
Logger.warning(
"image_plug: variant persistence #{inspect(module)} write failed for " <>
"#{action}=#{inspect(name)}: #{inspect(reason)}"
)
end
end
defp seed_public(table) do
public = %Variant{
name: "public",
pipeline: Pipeline.new(provider: Image.Plug.Provider.Cloudflare),
options: nil,
metadata: %{},
never_require_signed_urls?: true,
inserted_at: DateTime.utc_now(),
updated_at: DateTime.utc_now()
}
:ets.insert(table, {public.name, public})
end
defp do_seed(table, name, %Pipeline{} = pipeline) do
variant = %Variant{
name: name,
pipeline: pipeline,
options: nil,
metadata: %{},
never_require_signed_urls?: false,
inserted_at: DateTime.utc_now(),
updated_at: DateTime.utc_now()
}
:ets.insert(table, {name, variant})
end
defp do_seed(table, name, %Variant{} = variant) do
:ets.insert(table, {name, %{variant | name: name}})
end
defp do_seed(table, name, {provider, options_string})
when is_atom(provider) and is_binary(options_string) do
do_seed(table, name, {provider, options_string, []})
end
defp do_seed(table, name, {provider, options_string, variant_options})
when is_atom(provider) and is_binary(options_string) and is_list(variant_options) do
case parse_options_string(provider, options_string) do
{:ok, pipeline} ->
variant = %Variant{
name: name,
pipeline: pipeline,
options: options_string,
metadata: Keyword.get(variant_options, :metadata, %{}),
never_require_signed_urls?:
Keyword.get(variant_options, :never_require_signed_urls?, false),
inserted_at: DateTime.utc_now(),
updated_at: DateTime.utc_now()
}
:ets.insert(table, {name, variant})
{:error, error} ->
require Logger
Logger.warning(
"image_plug: failed to seed variant #{inspect(name)} from " <>
"options string #{inspect(options_string)}: #{inspect(error)}"
)
end
end
defp parse_options_string(Image.Plug.Provider.Cloudflare, options_string) do
Image.Plug.Provider.Cloudflare.Options.parse(options_string)
end
defp parse_options_string(_other_provider, _options_string) do
{:error, :no_seed_parser_for_provider}
end
end