Skip to main content

lib/image/plug/variant_store/ets/server.ex

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