defmodule SplitClient.Boundary.TreatmentCache do # A module to organize functionality around caching # Treatments for splits. Each cache_key is a three-item tuple # that looks like this `{lookup_key, split_name, options}`. # Having the cache_key be a tuple like this will allow us to # reuse the information to poll for Treatment updates in the # background using the original query paramaters @moduledoc false alias SplitClient.Boundary.OrderedAttributes alias SplitClient.Treatment @type key_data() :: {key :: String.t(), split_name :: String.t(), opts :: keyword()} @type t() :: %__MODULE__{treatments: map()} defstruct treatments: %{} def new(treatments \\ %{}), do: %__MODULE__{treatments: treatments} @doc """ Access a cached Treatment """ @spec get_treatment(t(), key_data()) :: Treatment.t() | nil def get_treatment(cache, key_data) do Map.get(cache.treatments, cache_key(key_data)) end @doc """ Get multiple treatments out of the cache at once. Returned as a map of %{split_name => %Treatment{}} """ @spec get_treatments(t(), String.t(), list(), keyword()) :: map() def get_treatments(cache, key, split_names, opts) do Enum.reduce(split_names, %{}, fn split_name, acc -> case get_treatment(cache, {key, split_name, opts}) do nil -> acc treatment -> Map.put(acc, treatment.split_name, treatment) end end) end @doc """ Cache a single treatment """ @spec cache_treatment(t(), Treatment.t(), key_data()) :: t() def cache_treatment(cache, treatment, key_data) do put_in(cache, [Access.key!(:treatments), cache_key(key_data)], treatment) end @doc """ Cache treatments that have been grouped by traffic_type ```elixir %{ "customer" => %{ "shiny-feature" => %Treatment{treatment: "on"}, }, "user" => %{ "my-experiment" => %Treatment{treatment: "off} } } ``` """ @spec cache_all_treatments(t(), map(), [map()], keyword()) :: t() def cache_all_treatments(cache, treatments, _keys, _opts) when map_size(treatments) == 0, do: cache def cache_all_treatments(cache, treatments, keys, opts) do Enum.reduce(keys, cache, fn key, acc -> splits = treatments[key.traffic_type] opts = add_bucketing_key_to_opts(opts, Map.get(key, :bucketing_key)) cache_treatments(acc, splits, key.matching_key, opts) end) end defp add_bucketing_key_to_opts(opts, nil), do: opts defp add_bucketing_key_to_opts(opts, bucketing_key), do: Keyword.put(opts, :bucketing_key, bucketing_key) @doc """ Cache multiple treatments. Pass in treatments in a map with each key being the split_name. ```elixir %{ "shiny-feature" => %Treatment{treatment: "on"}, "my-experiment" => %Treatment{treatment: "off} } ``` """ @spec cache_treatments(t(), map(), String.t(), keyword()) :: t() def cache_treatments(cache, treatments, key, opts) do Enum.reduce(treatments, cache, fn {split_name, treatment}, acc -> cache_treatment(acc, treatment, {key, split_name, opts}) end) end defp cache_key({key, split_name, opts}) do {key, split_name, ordered_opts(opts)} end defp ordered_opts(opts) do Enum.sort_by(opts, &elem(&1, 0)) |> Enum.map(&sort_option/1) end defp sort_option({key, value}) when is_map(value) do {key, OrderedAttributes.new(value).value} end defp sort_option(option), do: option @doc """ Get the split_names grouped by those that have the same key and options. This will make it possible to make a single query for multiple splits that were queried with the same key and options """ @spec group_split_names_by_key_and_options(t()) :: map() def group_split_names_by_key_and_options(cache) do group_by_key_and_options(cache) |> gather_split_names() end defp group_by_key_and_options(cache) do Enum.group_by( cache.treatments, fn {{key, _split_name, opts}, _treatment} -> {key, opts} end, fn {_query, treatment} -> treatment end ) end defp gather_split_names(data) do Enum.reduce(data, %{}, fn {query, treatments}, acc -> split_names = Enum.map(treatments, & &1.split_name) Map.put(acc, query, split_names) end) end end