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
| Module | What it is for |
|---|---|
Helper.Extra | Unix time, random strings and the builders of match specifications. |
Helper.UUID | Version 4 UUIDs. |
QueueAssistant | A first-in, first-out queue over Erlang's :queue. The library queues installs and event rebuilds with it. |
Helper.MnesiaAssistant | Start, stop, subscribe and join functions for Mnesia, and the conversion of records to maps and structs. |
Helper.MnesiaAssistant.Table | Create, wait for, clear and copy tables. |
Helper.MnesiaAssistant.Query | Read, write, delete and select records. |
Helper.MnesiaAssistant.Transaction | Run work in a transaction, and turn an aborted one into the library's error. |
Helper.MnesiaAssistant.Schema | Create and delete the Mnesia schema. |
Helper.MnesiaAssistant.Information | Read information about the running Mnesia. |
Helper.MnesiaAssistant.Error | Describe 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.
| Function | What it does | Returns |
|---|---|---|
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. |
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.
| Function | What it does | Returns |
|---|---|---|
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
| Function | What it does | Returns |
|---|---|---|
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
| Function | What it does | Returns |
|---|---|---|
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}. |
MnesiaAssistant.tuple_to_map([{MyApp.Note, 1, "a", "x"}], [:id, :title, :tag], nil, [:tag])
# [%{id: 1, title: "a"}]Helper.MnesiaAssistant.Table
| Function | What it does | Returns |
|---|---|---|
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.
| Function | What it does | Returns |
|---|---|---|
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
| Function | What it does | Returns |
|---|---|---|
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
| Function | What it does | Returns |
|---|---|---|
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
| Function | What it does | Returns |
|---|---|---|
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
| Function | What it does | Returns |
|---|---|---|
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
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
enddatabase_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:
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.
MyApp.Note in MishkaInstaller.MnesiaRepo.state().tables
# true
:mnesia.table_info(MyApp.Note, :storage_type)
# :disc_copiesUse the table
{: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:essentialwithout it stops the boot. The library logsessential 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
attributeslater, 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
:essentiallist from the seed, asdisc_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.MnesiaRepoyourself, with a temporarymnesia_dirand the same:essentiallist. See the test your plugins documentation.