Packages

ETS backed in-memory key-value cache with entry expiration after creation (TTL) or last access (idle-timout) and automatic value loading. Expired entries are automatically purged. Supports time travel. Trade memory for performance.

Current section

Files

Jump to
simple_mem_cache lib simple_mem_cache.ex
Raw

lib/simple_mem_cache.ex

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) | nil) :: :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 || &system_time/0).() + 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 | nil, boolean | nil, (() -> 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 | nil, 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 | nil) :: {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 | nil) :: 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
defp ets_get_status(_key, [], _minutes_keep_alive, _warn, _now) do
{:not_cached, nil, nil}
end
defp ets_get_status(key, [{ckey, value, expires, _}], minutes_keep_alive, _warn, _now)
when
key == ckey and
((minutes_keep_alive != nil and minutes_keep_alive >= 0) or
(is_nil(minutes_keep_alive) and is_nil(expires))) do
{:ok, value, expires}
end
defp ets_get_status(key, [{ckey, value, expires, count}], minutes_keep_alive, warn, now)
when
key == ckey and
warn and
count == 0 and
is_nil(minutes_keep_alive) and
expires <= now + 30 and expires > now do
{:expire_warning, value, expires}
end
defp ets_get_status(key, [{ckey, value, expires, _}], _minutes_keep_alive, _warn, now)
when
key == ckey and
expires != nil and
expires >= now do
{:ok, value, expires}
end
defp ets_get_status(key, [{ckey, old_value, expires, _}], _minutes_keep_alive, _warn, _now)
when
key == ckey do
{:expired, old_value, expires}
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 (4 = fourth position in tuple which hold the counter, 1 = increment one):
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.
# 3 = third position in tuple. It holds the expire time.
: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
# 3 = third position in tuple. It holds the expire time.
: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