lib/util/memcache.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.Memcache do
  @moduledoc """
  Easy-to-use in-memory cache for each executor pool.

  #{inspect(__MODULE__)} behaves as a key-value storage.
  Cached key-value pairs are internally stored in ETS.
  It accepts arbitrary (but not too large) terms as both keys and values.

  ## Usage

      iex> Antikythera.Memcache.write("foo", "bar", epool_id, 3_600)
      :ok
      iex> Antikythera.Memcache.read("foo", epool_id)
      {:ok, "bar"}

  ## Limits

  The number of records and the size of keys and values are limited.

  - The maximum number of records for each executor pool
    is #{AntikytheraCore.ExecutorPool.MemcacheWriter.max_records()}.
      - If exceeds the limit, a record nearest to expiration is evicted so that a new record can be inserted.
  - The maximum size of keys and values is defined in `Antikythera.Memcache.Key` and `Antikythera.Memcache.Value`.
      - To know how the size of keys and values is calculated, see `Antikythera.TermUtil`.
      - If exceeds the limit, `write/5` returns an error `:too_large_key` or `:too_large_value`.

  ## Lifetime of records

  There are 2 cases where records in #{inspect(__MODULE__)} are evicted:

  1. Records are expired (see [Mechanism of Expiration](#module-mechanism-of-expiration) below for more details)
  2. Reach the maximum number of records for each executor pool (as mentioned in [Limits](#module-limits))

  Please note that records in #{inspect(__MODULE__)} could be evicted anytime.

  ## Mechanism of Expiration

  The lifetime of records must be set as `lifetime_in_sec` in `write/5`.
  This lifetime does not guarantee that records remain in the entire specified lifetime.

  To avoid the [thundering herd](https://en.wikipedia.org/wiki/Thundering_herd_problem), whether records are expired is decided probabilistically.
  The probability of expiration is shown in the following.

  ![Mechanism of Expiration](assets/MemcacheExpiration.png)

  If the thundering herd becomes a big problem, adjust `prob_lifetime_ratio` in `write/5`.
  """

  @default_ratio 0.9

  alias Croma.Result, as: R
  alias AntikytheraCore.ExecutorPool.MemcacheWriter
  alias Antikythera.ExecutorPool.Id, as: EPoolId
  alias AntikytheraCore.Ets.Memcache
  alias Antikythera.Memcache.{Key, Value, NormalizedFloat}

  @doc """
  Read the value associated with the `key` from #{inspect(__MODULE__)}.

  Please note that records in #{inspect(__MODULE__)} could be evicted anytime so the error handling must be needed.
  """
  defun read(key :: Key.t(), epool_id :: v[EPoolId.t()]) :: R.t(Value.t(), :not_found) do
    Memcache.read(key, epool_id)
    |> R.bind(fn {_, expire_at, prob_expire_at, value} ->
      if expired?(expire_at, prob_expire_at) do
        {:error, :not_found}
      else
        {:ok, value}
      end
    end)
  end

  @doc """
  Try to read a value associated with the `key` from #{inspect(__MODULE__)} and if that fails,
  write a value returned by `fun` to #{inspect(__MODULE__)}.

  `fun` is evaluated only if a value is not found,
  and the new value returned by `fun` is stored in #{inspect(__MODULE__)}.
  If a value is found in #{inspect(__MODULE__)} or writing the new value to #{inspect(__MODULE__)} succeeds,
  the value is returned as `{:ok, value}`, but if writing the new value fails, an error is returned in the same manner as `write/5`.

  Parameters `lifetime_in_sec` and `prob_lifetime_ratio` are used to call `write/5` and the details are described above.
  """
  defun read_or_else_write(
          key :: Key.t(),
          epool_id :: v[EPoolId.t()],
          lifetime_in_sec :: v[non_neg_integer],
          prob_lifetime_ratio :: v[NormalizedFloat.t()] \\ @default_ratio,
          fun :: (() -> Value.t())
        ) :: R.t(Value.t(), :too_large_key | :too_large_value) do
    case read(key, epool_id) do
      {:ok, value} ->
        {:ok, value}

      {:error, _not_found} ->
        value = fun.()

        case write(key, value, epool_id, lifetime_in_sec, prob_lifetime_ratio) do
          :ok -> {:ok, value}
          err -> err
        end
    end
  end

  defp expired?(expire_at, prob_expire_at) do
    case System.monotonic_time(:millisecond) do
      now when now < prob_expire_at ->
        false

      now when expire_at < now ->
        true

      now ->
        rnd = :rand.uniform()
        t0 = expire_at - prob_expire_at
        t1 = now - prob_expire_at
        rnd < t1 / t0
    end
  end

  @doc """
  Write a key-value pair to #{inspect(__MODULE__)}.

  See above descriptions for more details.
  """
  defun write(
          key :: Key.t(),
          value :: Value.t(),
          epool_id :: v[EPoolId.t()],
          lifetime_in_sec :: v[non_neg_integer],
          prob_lifetime_ratio :: v[NormalizedFloat.t()] \\ @default_ratio
        ) :: :ok | {:error, :too_large_key | :too_large_value} do
    cond do
      not Key.valid?(key) -> {:error, :too_large_key}
      not Value.valid?(value) -> {:error, :too_large_value}
      true -> MemcacheWriter.write(key, value, epool_id, lifetime_in_sec, prob_lifetime_ratio)
    end
  end

  defmodule Key do
    @max_size 128

    @moduledoc """
    A type module of keys for `Antikythera.Memcache`.

    The maximum size of keys is #{@max_size} bytes.
    To know how the size is calculated, see `Antikythera.TermUtil`.
    """

    @type t :: any
    defun valid?(key :: term) :: boolean do
      Antikythera.TermUtil.size_smaller_or_equal?(key, @max_size)
    end

    defun max_size() :: non_neg_integer, do: @max_size
  end

  defmodule Value do
    @max_size 65_536

    @moduledoc """
    A type module of values for `Antikythera.Memcache`.

    The maximum size of values is #{@max_size} bytes.
    To know how the size is calculated, see `Antikythera.TermUtil`.
    """

    @type t :: any
    defun valid?(value :: term) :: boolean do
      Antikythera.TermUtil.size_smaller_or_equal?(value, @max_size)
    end

    defun max_size() :: non_neg_integer, do: @max_size
  end

  defmodule NormalizedFloat do
    use Croma.SubtypeOfFloat, min: 0.0, max: 1.0
  end
end