defmodule SimpleMemCache do @moduledoc ~S''' In-memory key-value cache with expiration-time after creation/modification/access, automatic value loading and time travel support. ''' @typedoc "The table id (Tid) or table name" @type table :: number | atom @cache_state_key Atom.to_string(__MODULE__) <> "_state" @doc ~S''' Time travel for SimpleMemCache. The `f_system_time` parameter is meant for time travel support. You can provide a function that returns the Unix/Posix UTC time in seconds. Set nil to restore the normal system time. ''' @spec set_system_time_function(table, (() -> integer)) :: :ok def set_system_time_function(table, f_system_time \\ nil) do # in case we travel back in time, we stil want to check expired entries every minute: next_expire_time = f_system_time.() + 60 true = :ets.insert(table, {@cache_state_key, f_system_time, next_expire_time}) :ok end @doc "returns function that generates Unix/Posix UTC time in seconds." def get_system_time_function(table) do {f_system_time, _} = get_cache_state!(table) f_system_time end @doc ~S''' Cache value returned by the supplied function. * `table` - Name of the ets table. * `key` - Key name * `minutes_valid` - Minutes to keep the item in cache. Default: nil - do not expire. * `keep_alive` - Keep the item in cache if it still accessed. It expires if it is not retrieved in `minutes_valid` minutes. Default: false * `f_new_value` - function that supplies the value. Enables automatic value loading. When `minutes_valid` is not nil and `keep_alive` is false, this function can be launched in a new Erlang process, and it can do this proactively up to 30 seconds before expiration. ## Examples products = SimpleMemCache.cache(SimpleMemCache, "products", 30, true, fn() -> File.read!(filename) end ) news_page = SimpleMemCache.cache(SimpleMemCache, "news", 10, &scrape_news/0) ''' @spec cache(table, Map.key, integer, boolean, (() -> any)) :: any def cache(table, key, minutes_valid \\ nil, keep_alive \\ false, f_new_value) do cache_value = ets_get(table, {key, (if keep_alive, do: minutes_valid, else: nil), true}, get_cache_state!(table)) case cache_value do {:ok, value} -> value {:expire_warning, value} -> spawn(fn -> try do put(table, key, minutes_valid, f_new_value.()) rescue ArgumentError -> :error # table is deleted, ignore end end) value _ -> put(table, key, minutes_valid, f_new_value.()) end end @doc "Create or update an item in cache." @spec put(table, Map.key, integer, Map.value) :: any def put(table, key, minutes_valid \\ nil, value) do ets_put(table, {key, minutes_valid, value}, get_cache_state!(table)) end @doc "Remove an item from cache. Returns the value of the removed object." @spec remove(table, Map.key) :: any def remove(table, key) do ets_remove(table, {key}, get_cache_state!(table)) end @doc ~S''' Get status and value of cached item. Status can be :ok, :expired or :not_cached The `minutes_keep_alive` parameter is the number of minutes to keep the item at least in cache. Does not shorten a previously set expiration time (use put for that). However, if there wasn't an expiration time it will take the new value. Default: nil - do not change the expire time. ## Example iex(1)> products = SimpleMemCache.get(SimpleMemCache, "products", 20) {:expired, "Fret dots"} ''' @spec get(table, Map.key, integer) :: {term, any} def get(table, key, minutes_keep_alive \\ nil) do ets_get(table, {key, minutes_keep_alive, false}, get_cache_state!(table)) end @doc "Get value of cached item. Nil if is not cached or when value is nil." @spec get!(table, Map.key, integer) :: any def get!(table, key, minutes_keep_alive \\ nil) do {_, value} = get(table, key, minutes_keep_alive) value end @doc "Remove expired entries. This is automatically called once a minute during SimpleMemCache usage." @spec remove_expired_entries(table) :: :ok def remove_expired_entries(table) do ets_remove_expired_entries(table, get_cache_state!(table)) end @doc "Delete the ets table" @spec stop(table) :: :ok def stop(table) do ets_delete(table) end ## ## The guts ## # return Unix/Posix UTC time in seconds defp system_time do :os.system_time(:seconds) end defp get_cache_state!(table) do key = @cache_state_key case :ets.lookup(table, key) do [{^key, f_system_time, expire_check_time}] -> {f_system_time || &system_time/0, expire_check_time} _ -> {&system_time/0, nil} end end defp set_expire_check_time(table, f_system_time, expire_check_time) do :ets.insert(table, {@cache_state_key, f_system_time, expire_check_time}) end defp check_expired(table, now, expire_check_time) do if !is_nil(expire_check_time) and now >= expire_check_time do remove_expired_entries(table) end :ok end defp ets_put(table, {key, minutes_valid, value}, {f_system_time, expire_check_time}) do now = f_system_time.() expires = if is_nil(minutes_valid), do: nil, else: now + minutes_valid * 60 true = :ets.insert(table, {key, value, expires, 0}) # if we didn't check for expired items and now this item has gotten an expire time if is_nil(expire_check_time) and minutes_valid != nil do # check every minute for expired items: set_expire_check_time(table, f_system_time, f_system_time.() + 60) else check_expired(table, now, expire_check_time) end value end # delete the whole table defp ets_delete(table) do true = :ets.delete(table) :ok end defp ets_remove(table, {key}, {f_system_time, expire_check_time}) do removed = :ets.take(table, key) check_expired(table, f_system_time.(), expire_check_time) if length(removed) == 1 do [{^key,value,_,_}] = removed value else nil end end @lint [{Credo.Check.Refactor.ABCSize,false}, {Credo.Check.Refactor.CyclomaticComplexity, false}] defp ets_get_status(key, map_value, minutes_keep_alive, warn, now) do case map_value do [] -> {:not_cached, nil, nil} [{^key, value, expires, _}] when (minutes_keep_alive != nil and minutes_keep_alive >= 0) or (is_nil(minutes_keep_alive) and is_nil(expires)) -> {:ok, value, expires} [{^key, value, expires, count}] when warn and count == 0 and is_nil(minutes_keep_alive) and expires <= now + 30 and expires > now -> {:expire_warning, value, expires} [{^key, value, expires, _}] when expires != nil and expires >= now -> {:ok, value, expires} [{^key, old_value, expires, _}] -> {:expired, old_value, expires} end end defp ets_get_expire_warning(table, key, status, f_system_time) do # is this really The One? if status == :expire_warning do # atomic operation: warning_count = :ets.update_counter(table, key, {4, 1}) if warning_count != 1 do # pitty, degrade to ok status :ok else # Greetings Neo, buy yourself some time # The client interface cache-method handles expire_warnings, therefore: # 1 till 30 seconds before expiring, the first caller gets a warning and 30 seconds to come with a new value. # During the next 30 seconds other clients receive ok, with the existing cached value. # After 30 seconds, if no new value is set, again an expire_warning will be given. # The purpose of this: for very frequent requested keys, don't create a give-me-new-value storm when the value expires; # assign the task for getting a new value to one client. :ets.update_element(table, key, {3, f_system_time.() + 60}) :expire_warning end else status end end defp ets_get(table, {key, minutes_keep_alive, warn}, {f_system_time, expire_check_time}) do now = f_system_time.() # get map_value = :ets.lookup(table, key) # determine status {status, value, expires} = ets_get_status(key, map_value, minutes_keep_alive, warn, now) status = ets_get_expire_warning(table, key, status, f_system_time) # if needed, set new expire time to keep this cached item alive if status != :not_cached and minutes_keep_alive != nil and (is_nil(expires) or (now + minutes_keep_alive * 60 >= expires + 30)) do :ets.update_element(table, key, {3, now + minutes_keep_alive * 60 + 30}) end # if we didn't check for expired items and now this item has gotten an expire time if status != :not_cached and is_nil(expire_check_time) and minutes_keep_alive != nil do # check every minute for expired items: set_expire_check_time(table, f_system_time, f_system_time.() + 60) else # check for expired entries check_expired(table, now, expire_check_time) end {status, value} end defp ets_remove_expired_entries(table, {f_system_time, _expire_check_time}) do now = f_system_time.() # remove old entries :ets.select_delete(table, [{{:"$1", :_, :"$2", :_}, [{:<, :"$2", now}], [true]}]) # are there any entries with an expire time left? temp_entries_count = :ets.select_count(table, [{{:"$1", :_, :"$2", :_}, [{:"/=", :"$2", nil}], [true]}]) next_expire_check_time = if temp_entries_count > 0, do: f_system_time.() + 60, else: nil set_expire_check_time(table, f_system_time, next_expire_check_time) :ok end end