Last active
October 21, 2021 17:49
-
-
Save greven/b0e66e273caffd39826ed076bfa8cca5 to your computer and use it in GitHub Desktop.
Elixir Simple Cache
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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