Current section

Files

Jump to
unleash_fresha lib unleash cache.ex
Raw

lib/unleash/cache.ex

defmodule Unleash.Cache do
@moduledoc """
This module is a cache backed by an ETS table. We use it to allow for multiple
threads to read the feature flag values concurrently on top of minimizing
network calls
"""
alias Unleash.Config
@cache_table_name :unleash_cache
@doc false
def cache_table_name, do: @cache_table_name
@doc """
Will create a new ETS table named `:unleash_cache`
"""
def init(existing_features \\ [], table_name \\ @cache_table_name) do
:ets.new(table_name, [:named_table, read_concurrency: true])
upsert_features(existing_features, table_name)
end
@doc """
Will return all values currently stored in the cache
"""
def get_all_feature_names(table_name \\ @cache_table_name) do
features = :ets.tab2list(table_name)
Enum.map(features, fn {_name, feature} ->
feature.name
end)
end
@doc """
Will return all features stored in the cache
"""
def get_features(table_name \\ @cache_table_name) do
features = :ets.tab2list(table_name)
Enum.map(features, fn {_name, feature} ->
feature
end)
end
@doc """
Will return the feature for the given name stored in the cache
"""
def get_feature(name, table_name \\ @cache_table_name) do
key = to_key(name)
case :ets.lookup(table_name, key) do
[{^key, feature}] -> feature
[] -> nil
end
end
@doc """
Will upsert (create or update) the given features in the cache
This will clear the existing peristed features to prevent stale reads
When `Unleash.Config.allowed_features/0` is set, only features whose name is in
that list are stored, keeping the cache minimal for services that use just a
few flags. When it is `nil` (the default), every feature is cached.
"""
def upsert_features(features, table_name \\ @cache_table_name) do
features =
features
|> filter_allowed(Config.allowed_features())
|> Enum.map(&prepare_feature(&1.name, &1))
:ets.delete_all_objects(table_name)
:ets.insert(table_name, features)
end
defp filter_allowed(features, nil), do: features
defp filter_allowed(features, allowed) do
allowed_names = MapSet.new(allowed, &normalize/1)
keep = with_dependency_parents(features, allowed_names)
Enum.filter(features, fn feature -> MapSet.member?(keep, normalize(feature.name)) end)
end
# Features that an allowed feature depends on must also be cached, otherwise
# the dependency cannot be resolved and the child would always read as
# disabled. We add the direct parents of every allowed feature to the keep
# set. A single level is sufficient because a parent that itself depends on
# another feature already disables its child (see `Unleash.Runtime`).
defp with_dependency_parents(features, allowed_names) do
parent_names =
features
|> Enum.filter(fn feature -> MapSet.member?(allowed_names, normalize(feature.name)) end)
|> Enum.flat_map(&dependency_keys/1)
|> MapSet.new()
MapSet.union(allowed_names, parent_names)
end
defp dependency_keys(%{dependencies: deps}) when is_list(deps) do
Enum.map(deps, fn dep -> normalize(dep["feature"]) end)
end
defp dependency_keys(_feature), do: []
# Case-insensitive string form of a feature name, used only to test allowlist
# membership. Comparing on strings avoids interning every server-sent name as
# an atom: only the features we actually keep are turned into atom keys (see
# `to_key/1`), so the atom table grows with the cached flags, not the feed.
defp normalize(name) when is_binary(name), do: String.downcase(name)
defp normalize(name) when is_atom(name), do: Atom.to_string(name)
defp prepare_feature(name, value) do
{to_key(name), value}
end
# Feature names are stored as lowercase atoms. Binary names (e.g. from the
# Unleash API) are downcased and atomized so storage is case-insensitive. Atoms
# are assumed to already be lowercase and are used directly as the key, so the
# `enabled?(:my_feature)` lookup path does zero conversion. Feature names are
# bounded by the number of flags, so dynamic atom creation is safe here.
defp to_key(name) when is_binary(name), do: name |> String.downcase() |> String.to_atom()
defp to_key(name) when is_atom(name), do: name
end