defmodule ActiveMemory.Store do
@moduledoc """
# The Store
## Store API
- `Store.all/0` Get all records stored
- `Store.delete/1` Delete the record provided
- `Store.delete_all/0` Delete all records stored
- `Store.one/1` Get one record matching either an attributes search or `match` query
- `Store.select/1` Get all records matching either an attributes search or `match` query
- `Store.withdraw/1` Get one record matching either an attributes search or `match` query, delete the record and return it
- `Store.write/1` Write a record into the memmory table
## Concurrency
A `Store` is a `GenServer`, but the data functions above (`all/0`, `one/1`,
`select/1`, `write/1`, `delete/1`, `withdraw/1`, `delete_all/0`) are **not**
routed through that process and are **not** serialized by it. They are ordinary
module functions that run in the **caller's** process, delegating straight to the
table's adapter (and therefore to `:ets`/`:mnesia`). Concurrency is governed by
ETS/Mnesia themselves, so many processes read and write in parallel — the single
`GenServer` is not a bottleneck. Only lifecycle and metadata operations (`init`,
`state/0`, `reload_seeds/0`) actually use the `GenServer`.
These functions live on the `GenServer` module purely for **organization**: the
`Store` is the single place responsible for how the application talks to its
table, following the Single Responsibility Principle. See the
[S.T.O.N.E principles](https://www.hpt-consulting.org/blog/stone-principles) for
the broader design philosophy.
## Expiry (TTL)
When the `Store`'s `Table` declares a `ttl` (see `ActiveMemory.Table`), records
expire automatically. Expiry is enforced in two ways: reads (`one/1`, `select/1`,
`all/0`, `withdraw/1`) never return an expired record, and the `Store` periodically
sweeps expired records to reclaim memory. The sweep cadence defaults to one minute
and can be set with the `sweep_interval` option (milliseconds):
```elixir
defmodule MyApp.Tokens.Store do
use ActiveMemory.Store,
table: MyApp.Tokens.Token,
sweep_interval: :timer.seconds(30)
end
```
The sweep only runs when the table has a `ttl`; otherwise it is never scheduled.
## Seeding
When starting a `Store` there is an option to provide a valid seed file and have the `Store` auto load seeds contained in the file.
```elixir
defmodule MyApp.People.Store do
use ActiveMemory.Store,
table: MyApp.People.Person,
seed_file: Path.expand("person_seeds.exs", __DIR__)
end
```
## Before `init`
All stores are `GenServers` and have `init` functions. While those are abstracted you can still specify methods to run during the `init` phase of the GenServer startup. Use the `before_init` keyword and add the methods as tuples with the arguments.
```elixir
defmodule MyApp.People.Store do
use ActiveMemory.Store,
table: MyApp.People.Person,
before_init: [{:run_me, ["arg1", "arg2", ...]}, {:run_me_too, []}]
end
```
> #### `before_init` and table recovery {: .warning}
>
> For ETS stores, the table is preserved across a store crash/restart by
> `ActiveMemory.TableHeir`. On such a recovery seed files are *not* re-run, but
> `before_init` methods **always** run, including on recovery. If a
> `before_init` method writes records with unique or generated keys (for
> example a `uuid`), running it again on recovery can create duplicates.
>
> How to handle this is left to the implementer. One option is to make any
> `before_init` write follow a "find or create" pattern — check with `one/1`
> before calling `write/1` — so the method is idempotent across restarts:
>
> ```elixir
> def run_me(args) do
> record = build_record(args)
>
> case one(%{key: record.key}) do
> {:ok, existing} -> {:ok, existing}
> {:error, :not_found} -> write(record)
> end
> end
> ```
## Initial State
All stores are `GenServers` and thus have a state. The default state is a map as such:
```elixir
%{started_at: "date time when first started", table_name: MyApp.People.Person}
```
This default state can be overwritten with a new state structure or values by supplying a method and arguments as a tuple to the keyword `initial_state`. The method must return `{:ok, new_state}`.
```elixir
defmodule MyApp.People.Store do
use ActiveMemory.Store,
table: MyApp.People.Person,
initial_state: {:initial_state_method, ["arg1", "arg2", ...]}
end
```
"""
@default_sweep_interval :timer.seconds(60)
defmacro __using__(opts) do
quote do
import unquote(__MODULE__)
use GenServer
alias ActiveMemory.Operations
opts = unquote(Macro.expand(opts, __CALLER__))
@table Keyword.get(opts, :table)
@before_init Keyword.get(opts, :before_init, :default)
@initial_state Keyword.get(opts, :initial_state, :default)
@seed_file Keyword.get(opts, :seed_file, nil)
@sweep_interval Keyword.get(opts, :sweep_interval, unquote(@default_sweep_interval))
def start_link(options \\ []) do
GenServer.start_link(__MODULE__, options, name: __MODULE__)
end
@impl true
def init(_) do
with {:ok, table_status} <- create_table(),
{:ok, :seed_success} <- __maybe_run_seeds__(table_status),
{:ok, _result} <- Operations.before_init(@before_init, __MODULE__),
{:ok, initial_state} <- __initial_state__() do
__schedule_sweep__()
{:ok, initial_state}
end
end
@spec all() :: list(map())
def all, do: Operations.all(@table)
def create_table, do: Operations.create_table(@table)
@spec delete(any()) :: :ok | {:error, any()}
def delete(struct), do: Operations.delete(struct, @table)
@spec delete_all() :: :ok | {:error, any()}
def delete_all, do: Operations.delete_all(@table)
@spec one(map() | list(any())) :: {:ok, map()} | {:error, any()}
def one(query), do: Operations.one(query, @table)
def reload_seeds do
GenServer.call(__MODULE__, :reload_seeds)
end
@spec select(map() | list(any())) :: {:ok, list(map())} | {:error, any()}
def select(query), do: Operations.select(query, @table)
def state do
GenServer.call(__MODULE__, :state)
end
@spec withdraw(map() | list(any())) :: {:ok, map()} | {:error, any()}
def withdraw(query), do: Operations.withdraw(query, @table)
@spec write(map()) :: {:ok, map()} | {:error, any()}
def write(struct), do: Operations.write(struct, @table)
@impl true
def handle_call(:reload_seeds, _from, state) do
{:reply, Operations.seed(@seed_file, @table), state}
end
@impl true
def handle_call(:state, _from, state), do: {:reply, state, state}
# Sent by `ActiveMemory.TableHeir` when it hands a recovered ETS table back
# to this store on restart. Ownership transfers with the message; nothing
# further is required here.
@impl true
def handle_info({:"ETS-TRANSFER", _table_ref, _from, _data}, state) do
{:noreply, state}
end
# Periodically reclaims memory from expired records when the table has a `ttl`.
def handle_info(:sweep, state) do
Operations.delete_expired(@table, System.system_time(:millisecond))
__schedule_sweep__()
{:noreply, state}
end
def handle_info(_message, state), do: {:noreply, state}
# A recovered table already holds its data, so seeding is skipped to avoid
# duplicating or clobbering the surviving records.
defp __maybe_run_seeds__(:recovered), do: {:ok, :seed_success}
defp __maybe_run_seeds__(:created), do: Operations.seed(@seed_file, @table)
# Schedules the next expiry sweep only when the table actually uses a `ttl`.
defp __schedule_sweep__ do
case @table.__attributes__(:ttl) do
nil -> :ok
_ttl -> Process.send_after(self(), :sweep, @sweep_interval)
end
end
# Only the clause matching the compile-time option is generated so the
# Elixir 1.19+ type checker never sees an unreachable clause.
if @initial_state == :default do
defp __initial_state__ do
{:ok,
%{
started_at: DateTime.utc_now(),
table_name: @table
}}
end
else
defp __initial_state__ do
{method, args} = @initial_state
:erlang.apply(__MODULE__, method, args)
end
end
end
end
end