Skip to content

Instantly share code, notes, and snippets.

@greven
Last active October 21, 2021 17:49
Show Gist options
  • Save greven/b0e66e273caffd39826ed076bfa8cca5 to your computer and use it in GitHub Desktop.
Save greven/b0e66e273caffd39826ed076bfa8cca5 to your computer and use it in GitHub Desktop.
Elixir Simple Cache
defmodule Elixir.Cache do
@moduledoc """
Author: Nuno M.
Warning: This is exploratory, not tested at all.
A simple Cache system implement using ETS as a way to
create in-memory cache named tables.
In order to keep concurrent access to the cache tables, methods
don't use the GenServer but use the ets functions directly.
Since the cache is created using the GenServer it will be garbage
collected when the GenServer itself is. By wrapping the Cache creation
on a GenServer it also gives us the possibility to implement scheduling
and clean up routines easily.
TODO: Implement a way to schedule clear / cleanup expired entries
TODO: Limit the size of the cache tables (watch for the size limit?)
"""
# TODO: Limit the number of entries (table size) per table, add a routine to clean up tables when reaching the limit or implement a round robin structure?
use GenServer
import Record
@tables [
{:skills_search, [limit: 100_000]}
]
@default_ttl :timer.hours(1)
@default_limit :infinite
@ets_options [
:set,
:public,
:named_table,
read_concurrency: true,
write_concurrency: true
]
# Cache Entry
defrecord(:entry, key: nil, value: nil, touched: nil, ttl: nil)
# Client Interface
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@doc """
Get the cache entry with the given `key` from the ETS `table`
"""
def get(table, key), do: get_entry(table, key)
@doc """
Get (list) all entries in the ETS cache in the given `table`.
"""
def get_all(table), do: list_entries(table)
@doc """
Runs a resolver function if the `key` in the
`table` is not already cached.
## Examples
with {:ok, products} <- Cache.resolve(:products, "product_uuid", fn -> Products.list_all() end) do
# Do something with products
end
"""
def resolve(table, key, resolver, opts \\ []) when is_function(resolver, 0) do
resolve_entry(table, key, resolver, opts)
end
@doc """
Put a new entry into the cache `table` with the given `key` and `value`.
"""
def put(table, key, value, opts \\ []), do: insert_entry(table, key, value, opts)
@doc """
Insert all `entries` into the `table`.
If the entries is a Map or Keyword List it will use the
key/value pairs into the cache. If a list of maps is provided,
it will use the `key` (defaults to :id) option.
"""
def put_all(entries, table, opts \\ []), do: insert_all_entries(entries, table, opts)
@doc """
Updates a cache entry from `table` with the given `key` and `value`.
"""
def update(table, key, changes), do: update_entry(table, key, changes)
@doc """
Delete an entry from the cache `table` with the given `key`.
"""
def delete(table, key), do: delete_entry(table, key)
@doc """
Clears all values in the cache table.
"""
def clear(table), do: clear_ets_table(table)
def get_table_config(table) do
GenServer.call(__MODULE__, {:table_config, table})
end
# Server Callbacks
def init(_state) do
create_tables(@tables)
state = set_default_options(@tables)
{:ok, state}
rescue
ArgumentError -> {:stop, :already_started}
end
# Create the cache @tables
defp create_tables(tables) do
tables
|> Enum.each(fn {table, _opts} -> create_ets_table(table) end)
end
defp set_default_options(tables) do
tables
|> Enum.map(fn {table, opts} ->
{table, Keyword.merge([limit: @default_limit], opts)}
end)
end
def handle_call({:table_config, table}, _from, state) do
{:reply, Keyword.get(state, table), state}
end
defp list_entries(table) do
list_values = fn list ->
Keyword.values(list) |> Enum.map(&elem(&1, 2))
end
case :ets.tab2list(table) do
[_ | _] = list -> list_values.(list)
_ -> nil
end
end
defp get_entry(table, key) do
case :ets.lookup(table, key) do
[] ->
:not_found
[{^key, {:entry, ^key, value, touched, ttl}}] ->
case expired?(touched, ttl) do
false ->
value
true ->
delete_entry(table, key)
:not_found
end
end
end
defp resolve_entry(table, key, resolver, opts) do
case get_entry(table, key) do
:not_found ->
with {:ok, result} <- resolver.() do
insert_entry(table, key, result, opts)
result
end
result ->
result
end
end
defp insert_entry(table, entry) do
key = entry(entry, :key)
:ets.insert(table, {key, entry})
end
defp insert_entry(table, key, value, opts) do
ttl = Keyword.get(opts, :ttl, @default_ttl)
:ets.insert(table, {key, entry(key: key, value: value, touched: now(), ttl: ttl)})
end
defp insert_all_entries(entries, table, opts) when is_list(entries) or is_map(entries) do
ttl = Keyword.get(opts, :ttl, @default_ttl)
map_key = Keyword.get(opts, :key, :id)
entries =
for entry <- entries do
case entry do
{key, value} -> entry(key: key, value: value, touched: now(), ttl: ttl)
_ -> entry(key: Map.get(entry, map_key), value: entry, touched: now(), ttl: ttl)
end
end
entries |> Enum.each(&insert_entry(table, &1))
end
defp update_entry(table, key, changes) do
{:ok, :ets.update_element(table, key, changes)}
end
defp delete_entry(table, key) do
:ets.delete(table, key)
end
defp create_ets_table(table) do
:ets.new(table, @ets_options)
end
defp clear_ets_table(table) do
:ets.delete(table)
create_ets_table(table)
end
# Retrieves the size of the cache.
defp get_ets_table_size(table) do
:ets.info(table, :size)
end
defp expired?(touched, ttl), do: now() - touched >= ttl
# Return current timestamp in milliseconds
defp now, do: System.system_time(:millisecond)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment