Helper modules and your own tables

v0.1.14

Mishka Installer builds its own tables with a few small helper modules, and they are public. This page lists them: each module, every public function, what it does and what it returns. It ends with a worked example, a table of your own that the library creates when it boots. All modules sit under MishkaInstaller. The helpers wrap Erlang's :mnesia, so the Mnesia documentation says what each call does underneath.

The modules

ModuleWhat it is for
Helper.ExtraUnix time, random strings and the builders of match specifications.
Helper.UUIDVersion 4 UUIDs.
QueueAssistantA first-in, first-out queue over Erlang's :queue. The library queues installs and event rebuilds with it.
Helper.MnesiaAssistantStart, stop, subscribe and join functions for Mnesia, and the conversion of records to maps and structs.
Helper.MnesiaAssistant.TableCreate, wait for, clear and copy tables.
Helper.MnesiaAssistant.QueryRead, write, delete and select records.
Helper.MnesiaAssistant.TransactionRun work in a transaction, and turn an aborted one into the library's error.
Helper.MnesiaAssistant.SchemaCreate and delete the Mnesia schema.
Helper.MnesiaAssistant.InformationRead information about the running Mnesia.
Helper.MnesiaAssistant.ErrorDescribe a Mnesia error and log it.

Helper.Extra

Small functions with no dependencies. The library uses get_unix_time/0 to fill the inserted_at and updated_at fields of its records.

FunctionWhat it doesReturns
get_unix_time()Reads the current time as a Unix timestamp in seconds.An integer.
randstring(count)Builds a random string of count letters and digits, in upper case. Do not use it for secrets.A string.
erlang_result(selector)Turns :all into [:"$_"], the whole record, and :selected into [:"$$"], the selected fields. Any other value comes back as it is.A list, or the value you gave.
erlang_fields(tuple, fields, keys, num)Builds the head of a match specification. tuple is the table in a tuple, such as {MyApp.Note}. fields lists every field of the record in order. keys names the fields to select. Each selected field becomes :"$1", :"$2" and so on, counting from num, and every other field becomes :_.A tuple.
elixir
Extra.erlang_fields({MyApp.Note}, [:id, :title, :tag], [:tag], 1)
# {MyApp.Note, :_, :_, :"$1"}

Helper.UUID

The library gives every plugin and installed library an id from generate/0.

FunctionWhat it doesReturns
generate()Builds a random version 4 UUID.A 36-character lowercase string, such as "c63aea42-209a-40fb-b5c6-a0d28ee7e25b".
bingenerate()Builds the same kind of UUID in its raw form.A 16-byte binary.

QueueAssistant

FunctionWhat it doesReturns
new()Builds an empty queue.A queue.
insert(queue, item)Adds item at the rear.The new queue.
out(queue)Takes the item at the front.{{:value, item}, queue}, or {:empty, queue} when the queue is empty.
empty?(queue)Checks whether the queue holds nothing. nil counts as empty.A boolean.

Helper.MnesiaAssistant

FunctionWhat it doesReturns
start()Starts Mnesia, as :mnesia.start/0 does.:ok or {:error, reason}.
stop()Stops Mnesia, as :mnesia.stop/0 does.:stopped or {:error, reason}.
change_config(nodes)Connects the running node, which has an empty schema, to the Mnesia nodes in nodes. It is :mnesia.change_config(:extra_db_nodes, nodes).{:ok, connected_nodes}, an empty list when no node answers, or {:error, reason}.
subscribe(what)Subscribes the calling process to Mnesia events, such as :system.{:ok, node} or {:error, reason}.
unsubscribe(what)Cancels that subscription.{:ok, node} or {:error, reason}.
er(selector)The same as Extra.erlang_result/1.A list, or the value you gave.
erl_fields(tuple, fields, keys, num)The same as Extra.erlang_fields/4.A tuple.
tuple_to_map(records, fields, struct, drop)Turns record tuples into maps or structs. It pairs each tuple, without its first element (the table name), with fields, removes the keys in drop, and builds struct with struct!/2 when it is not nil. :"$end_of_table" gives []. A paged result {records, continuation} keeps its continuation.A list of maps or structs, or {list, continuation}.
elixir
MnesiaAssistant.tuple_to_map([{MyApp.Note, 1, "a", "x"}], [:id, :title, :tag], nil, [:tag])
# [%{id: 1, title: "a"}]

Helper.MnesiaAssistant.Table

FunctionWhat it doesReturns
create_table(table, opts)Creates a table from a keyword list of options such as attributes:, index: and disc_copies:. It is :mnesia.create_table/2.{:atomic, :ok} or {:aborted, reason}.
wait_for_tables(tables, timeout)Waits until the tables are loaded, for timeout milliseconds at most. timeout can be :infinity.:ok, {:timeout, tables} with the tables still loading, or {:error, reason}.
clear_table(table)Removes every record of a table.{:atomic, :ok} or {:aborted, reason}.
all_keys(table)Lists every key of a table. Run it inside an activity, such as Transaction.ets/1.A list.
add_table_copy(table, node, type)Copies a table, or :schema, to node. type is :ram_copies, :disc_copies or :disc_only_copies.{:atomic, :ok} or {:aborted, reason}.
change_table_copy_type(table, node, type)Changes how a table, or :schema, is stored on node.{:atomic, :ok} or {:aborted, reason}.

Helper.MnesiaAssistant.Query

Every function here must run inside a Transaction activity.

FunctionWhat it doesReturns
read(table, key)Reads the records stored under key.A list of record tuples.
index_read(table, key, attr)Reads the records whose indexed field attr equals key.A list of record tuples.
write(record)Writes a whole record tuple, {Table, field1, field2, ...}.:ok.
delete(table, key, lock)Deletes the record at key. lock is :write or :sticky_write.:ok.
select(table, spec)Runs a match specification against a table.A list.
match_object(pattern)Finds every record that matches pattern, a record tuple where :_ matches anything.A list of record tuples.

Helper.MnesiaAssistant.Transaction

FunctionWhat it doesReturns
transaction(fun)Runs fun in a Mnesia transaction. The commit is buffered, so a node that dies before Mnesia's periodic dump, every three minutes by default, can lose it.{:atomic, result} or {:aborted, reason}.
durable_transaction(fun)Runs fun with :mnesia.sync_transaction/1, which commits on every node that holds the table, then flushes the transaction log to the disc before it returns. Use it for a write that must survive a crash. The library writes plugin statuses and install records with it. It exists since 0.1.11.{:atomic, result} or {:aborted, reason}.
ets(fun)Runs fun as a fast, dirty ets activity. It is safe only for ram_copies tables, or for reading a disc_copies table.What fun returns.
transaction_error(reason, module, type, field, action)Builds the library's error from the reason of an aborted transaction. type completes the message "there is a problem in <type> data in the database", such as "reading".{:error, [%{message: message, field: field, action: action, source: source}]}.

Helper.MnesiaAssistant.Schema

FunctionWhat it doesReturns
create_schema(nodes \\ [node()])Creates the Mnesia schema on nodes.:ok or {:error, reason}. A schema that exists is an error.
delete_schema(nodes \\ [node()])Deletes the schema on the disc of nodes, so a node can join a cluster from an empty one. Mnesia must be stopped on those nodes.:ok or {:error, reason}.

Helper.MnesiaAssistant.Information

FunctionWhat it doesReturns
system_info(type)Reads information about the running Mnesia, such as :db_nodes, :extra_db_nodes, :local_tables, :tables or :directory. It is :mnesia.system_info/1.The value for type.

Helper.MnesiaAssistant.Error

FunctionWhat it doesReturns
error_description(error)Describes a Mnesia error. It is :mnesia.error_description/1.A term, often a charlist.
error_description(result, identifier)Normalises a Mnesia result and logs it under identifier. A success and an "already exists" result are logged at debug level, and any other failure as an error.{:ok, :atomic} for :ok, {:atomic, :ok} and :starting. Otherwise {:error, error, description}.

Example: a table the library creates on boot

MishkaInstaller.MnesiaRepo creates one Mnesia table for each module in its :essential list when it boots. It calls database_config/0 of the module, gives the keyword list to Table.create_table/2, and names the table after the module. A module needs that one function. The example below stores notes with an index on tag, and uses the helpers above for the id, the time, the writes, the reads and a match specification. The Event and Installer tables of the library use UUID.generate/0 and Extra.get_unix_time/0 the same way, as the auto: generators of GuardedStruct fields.

Write the module

elixir
defmodule MyApp.Note do
  alias MishkaInstaller.Helper.{Extra, MnesiaAssistant, UUID}
  alias MishkaInstaller.Helper.MnesiaAssistant.{Query, Transaction}

  @fields [:id, :title, :tag, :inserted_at]

  defstruct @fields

  def database_config do
    [type: :set, attributes: @fields, index: [:tag], disc_copies: [node()]]
  end

  def write(title, tag) do
    note = %__MODULE__{
      id: UUID.generate(),
      title: title,
      tag: tag,
      inserted_at: Extra.get_unix_time()
    }

    record = List.to_tuple([__MODULE__ | Enum.map(@fields, &Map.fetch!(note, &1))])

    case Transaction.durable_transaction(fn -> Query.write(record) end) do
      {:atomic, :ok} ->
        {:ok, note}

      {:aborted, reason} ->
        Transaction.transaction_error(reason, __MODULE__, "storing", :global, :database)
    end
  end

  def get(id) do
    with {:ok, notes} <- read(fn -> Query.read(__MODULE__, id) end) do
      {:ok, List.first(notes)}
    end
  end

  def by_tag(tag), do: read(fn -> Query.index_read(__MODULE__, tag, :tag) end)

  def tags do
    head = MnesiaAssistant.erl_fields({__MODULE__}, @fields, [:tag], 1)
    spec = [{head, [], MnesiaAssistant.er(:selected)}]

    case Transaction.transaction(fn -> Query.select(__MODULE__, spec) end) do
      {:atomic, rows} ->
        {:ok, rows |> List.flatten() |> Enum.uniq() |> Enum.sort()}

      {:aborted, reason} ->
        Transaction.transaction_error(reason, __MODULE__, "reading", :global, :database)
    end
  end

  defp read(fun) do
    case Transaction.transaction(fun) do
      {:atomic, records} ->
        {:ok, MnesiaAssistant.tuple_to_map(records, @fields, __MODULE__, [])}

      {:aborted, reason} ->
        Transaction.transaction_error(reason, __MODULE__, "reading", :global, :database)
    end
  end
end

database_config/0 is the table. attributes names the fields in the order of the record tuple, index: [:tag] makes tag readable with index_read/3, and disc_copies: [node()] keeps the table in RAM and on the disc of the node that creates it. A record is the tuple {MyApp.Note, id, title, tag, inserted_at}: the table name, then the fields in the order of attributes. write/2 uses durable_transaction/1, so the record is on the disc when it returns. The reads use transaction/1.

List the module in :essential

Setting the key replaces the default list, so keep the two tables of the library. In a release, put this in config/runtime.exs:

elixir
config :mishka_installer, MishkaInstaller.MnesiaRepo,
  essential: [
    MishkaInstaller.Installer.Installer,
    MishkaInstaller.Event.Event,
    MyApp.Note
  ]

Check the boot

On the first boot the library creates the table and emits [:mishka_installer, :mnesia, :table] with table: MyApp.Note and status: :created. On every later boot the table exists, and the status is :already_loaded. See the telemetry documentation for the other statuses.

elixir
MyApp.Note in MishkaInstaller.MnesiaRepo.state().tables
# true

:mnesia.table_info(MyApp.Note, :storage_type)
# :disc_copies

Use the table

elixir
{:ok, note} = MyApp.Note.write("Write a plugin", "elixir")
{:ok, _} = MyApp.Note.write("Call an event", "elixir")
{:ok, _} = MyApp.Note.write("Deploy", "ops")

MyApp.Note.get(note.id)
# {:ok, %MyApp.Note{id: "f589696c-63e5-4bc2-a93f-b517e7edb135", title: "Write a plugin", tag: "elixir", inserted_at: 1792398438}}

MyApp.Note.by_tag("elixir")
# {:ok, [%MyApp.Note{title: "Write a plugin"}, %MyApp.Note{title: "Call an event"}]}, in no fixed order

MyApp.Note.tags()
# {:ok, ["elixir", "ops"]}

Stop the node and start it again: the notes are still there, because the table is on the disc.

What to know

  • The module needs database_config/0. A module in :essential without it stops the boot. The library logs essential MyApp.Note exports no database_config/0, and the store does not start.
  • The library creates a missing table and never changes an existing one. If you add a field to attributes later, the table on the disc keeps its old columns, and a write of the new record is refused. Change the table once with :mnesia.transform_table/3.
  • A node that joins a cluster copies the tables in its own :essential list from the seed, as disc_copies. Give a joining node the same list as the seed. See the deploy to production documentation.
  • In the test environment the library starts only its PubSub. Start MishkaInstaller.MnesiaRepo yourself, with a temporary mnesia_dir and the same :essential list. See the test your plugins documentation.