View Source ActiveMemory.Store (ActiveMemory v0.8.1)

The Store

store-api

Store API

  • Store.all/1 Get all records stored, optionally ordered and paged (see Ordering and paging)
  • Store.count/1 Count the records stored, without reading them (see Counting)
  • Store.delete/1 Delete the record provided, matched in full (see Deleting a record)
  • Store.delete_all/0 Delete all records stored
  • Store.exists?/2 Whether any record matches an attributes search or match query
  • Store.get/1 Get the record with the given primary key, or {:error, :not_found}
  • Store.get!/1 Like get/1 but raises ActiveMemory.NotFoundError
  • Store.get_by/1 Get the single record matching an attributes search
  • Store.get_by!/1 Like get_by/1 but raises ActiveMemory.NotFoundError
  • Store.one/1 Get one record matching either an attributes search or match query. Raises ActiveMemory.MultipleResultsError when several match
  • Store.one!/1 Like one/1 but raises ActiveMemory.NotFoundError
  • Store.reload/1 Re-read a record from the table by its primary key
  • Store.reload!/1 Like reload/1 but raises ActiveMemory.NotFoundError
  • Store.select/2 Get all records matching either an attributes search or match query, optionally ordered and paged
  • Store.withdraw/1 Atomically get one record matching either an attributes search or match query, delete the record and return it — exactly one concurrent caller wins, making it safe for take-once workloads
  • Store.write/1 Write a record into the memory table, from a struct or an Ecto.Changeset. An invalid changeset is returned as {:error, changeset} with its action set to :insert, exactly like Ecto.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 counts

exists?/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)
end

The 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_init and table recovery

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:

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