View Source ActiveMemory.Store (ActiveMemory v0.8.2)
The Store
store-api
Store API
Store.all/1Get all records stored, optionally ordered and paged (see Ordering and paging)Store.count/1Count the records stored, without reading them (see Counting)Store.delete/1Delete the record provided, matched in full (see Deleting a record)Store.delete_all/0Delete all records storedStore.exists?/2Whether any record matches an attributes search ormatchqueryStore.get/1Get the record with the given primary key, or{:error, :not_found}Store.get!/1Likeget/1but raisesActiveMemory.NotFoundErrorStore.get_by/1Get the single record matching an attributes searchStore.get_by!/1Likeget_by/1but raisesActiveMemory.NotFoundErrorStore.one/1Get one record matching either an attributes search ormatchquery. RaisesActiveMemory.MultipleResultsErrorwhen several matchStore.one!/1Likeone/1but raisesActiveMemory.NotFoundErrorStore.reload/1Re-read a record from the table by its primary keyStore.reload!/1Likereload/1but raisesActiveMemory.NotFoundErrorStore.select/2Get all records matching either an attributes search ormatchquery, optionally ordered and pagedStore.withdraw/1Atomically get one record matching either an attributes search ormatchquery, delete the record and return it — exactly one concurrent caller wins, making it safe for take-once workloadsStore.write/1Write a record into the memory table, from a struct or anEcto.Changeset. An invalid changeset is returned as{:error, changeset}with itsactionset to:insert, exactly likeEcto.Repo.insert/2
reading-a-single-record
Reading a single record
get/1 reads by primary key — the table's first field, which is what ETS and
Mnesia key a record on. That is :uuid on a table using
auto_generate_uuid: true, an Ecto schema's declared primary key, or simply the
first field declared.
{:ok, person} = MyApp.People.Store.get(uuid)
person = MyApp.People.Store.get!(uuid) # raises ActiveMemory.NotFoundError
{:ok, person} = MyApp.People.Store.get_by(%{email: "kara@galactica.com"})A query that is meant to find one record but matches several raises
ActiveMemory.MultipleResultsError from one/1, one!/1, get_by/1 and
get_by!/1, as Ecto.Repo.one/2 does. Use select/2 when many records are
expected.
Because reads and writes match a record in full, a struct held across a change
goes stale; reload/1 gets the current copy.
counting
Counting
count/1 asks the table for its size (:ets.info/2, :mnesia.table_info/2), so
it is O(1) and never copies records out — unlike length(all()).
It is the size of the backend table, not a count under application level rules. On
a table with a ttl it includes records that have expired but have not been swept
yet, so it can exceed what the reads return; pass sweep: true to delete those
first and get a count that agrees with the reads. On a replicated Mnesia table it
is the size of the replica this node reads from, so nodes whose replicas have
diverged report different counts.
MyApp.Tokens.Store.count() # O(1), may include expired records
MyApp.Tokens.Store.count(sweep: true) # sweeps first, then countsexists?/2 has to match a query against fields, so it costs a scan rather than
being O(1). It accepts sweep: true as well, though the answer never depends on
it, since reads already ignore an expired record.
ordering-and-paging
Ordering and paging
all/1 and select/2 take :order_by, :limit and :offset:
MyApp.People.Store.all(order_by: :last, limit: 20)
MyApp.People.Store.all(order_by: [{:desc, :age}, :last], offset: 20, limit: 20)
MyApp.People.Store.select(%{cylon?: true}, order_by: :last)Neither ETS nor Mnesia can order a result, so this sorts after reading —
O(n log n) over the matched records, not an index backed sort. :limit and
:offset are convenience pagination, not indexed pagination: every matched
record is read and sorted before the offset is thrown away, so
offset: 10_000, limit: 10 pays for all 10,010. Without an :order_by the order
is whatever the table returns, which for a :set table is unspecified.
Values are compared with their own compare/2 when they have one, so Decimal,
DateTime, NaiveDateTime, Date and Time fields sort correctly instead of by
Erlang term order, which compares those structs field by field.
deleting-a-record
Deleting a record
delete/1 removes an exact record match: the struct you pass is compared
field for field against what is stored (:ets.delete_object/2,
:mnesia.delete_object/3). Pass a struct that has diverged from the stored copy
— a stale read, or one you modified in memory — and nothing is removed, yet the
call still returns :ok, the same answer delete/1 gives for a record that was
never there.
This is deliberate. It is the only correct behavior for a :bag table, where
several records share a key, and on a :set table it means a delete never
clobbers a newer version of a record written since you read it.
When you hold an identifier rather than a record you know is current, reach for
withdraw/1 instead. It matches on a query, so staleness cannot affect it, it is
atomic, and it tells you whether anything was actually removed:
case MyApp.People.Store.withdraw(%{uuid: uuid}) do
{:ok, person} -> # removed, and here is the record that was stored
{:error, :not_found} -> # nothing matched
end
concurrency
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 for
the broader design philosophy.
expiry-ttl
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):
defmodule MyApp.Tokens.Store do
use ActiveMemory.Store,
table: MyApp.Tokens.Token,
sweep_interval: :timer.seconds(30)
endThe sweep only runs when the table has a ttl; otherwise it is never scheduled.
seeding
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.
defmodule MyApp.People.Store do
use ActiveMemory.Store,
table: MyApp.People.Person,
seed_file: Path.expand("person_seeds.exs", __DIR__)
end
before-init
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.
defmodule MyApp.People.Store do
use ActiveMemory.Store,
table: MyApp.People.Person,
before_init: [{:run_me, ["arg1", "arg2", ...]}, {:run_me_too, []}]
end
before_initand table recoveryFor ETS stores, the table is preserved across a store crash/restart by
ActiveMemory.TableHeir. On such a recovery seed files are not re-run, butbefore_initmethods always run, including on recovery. If abefore_initmethod writes records with unique or generated keys (for example auuid), running it again on recovery can create duplicates.How to handle this is left to the implementer. One option is to make any
before_initwrite follow a "find or create" pattern — check withone/1before callingwrite/1— so the method is idempotent across restarts: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
Initial State
All stores are GenServers and thus have a state. The default state is a map as such:
%{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}.
defmodule MyApp.People.Store do
use ActiveMemory.Store,
table: MyApp.People.Person,
initial_state: {:initial_state_method, ["arg1", "arg2", ...]}
end