noizeramp

Aleksey's programming, engineering and electronics log

Writing mnesia data migrations in Elixir

by Aleksey Gureev
7 June 2019

Migrating data in Mnesia is traditionally a dark matter. Even if you understand the concept of translating tables, it’s still not clear how to organize your migrations so that they are picked up and run during the application start.

In this short post I share how I’m doing it. See if you have a better recipe. I’ll be using Elixir pieces here, but it’s pretty much the same for the Erlang projects.

Here’s how the table definition looks in Amnesia for this example:

use Amnesia

defdatabase Database do
  deftable Users, [:name, :email, :login_count], type: :bag do
    @type t :: %Users{name: String.t, email: String.t, login_count: integer}
  end
end

In the database definition we lay out the current version of the table. Previously, the table Users had name and email fields only. In the most recent version we added login_count field and now want to write a migration that will update the database by adding the attribute to the table rows and initialize it with 0.

Here I put the migrations in the main application module, but you may want to put them in a separate module or even a namespace to organize stuff nicely.

defmodule MyApp do
  use Application
  use Amnesia

  alias Database.Users

  def start(_type, _args) do
    import Supervisor.Spec

    Amnesia.Schema.create()
    Amnesia.start()
    Database.create(disk: [node()])

    case migrate_users() do
      :ok ->
        children = [
          # ...
        ]

        opts = [strategy: :one_for_one, name: PicsafeKeyServer.Supervisor]
        Supervisor.start_link(children, opts)

      :error ->
        {:error, :migration_error}
    end
  end



  # Versions of the user table
  @users_v1 [:name, :email]
  @users_v2 [:name, :email, :login_count]

  # Perform migrations of the users table until the recent version is current.
  # Returns :ok or :error.
  defp migrate_users() do
    case Users.info(:attributes) do
      @users_v1 ->
        Logger.info "Migrating users to 2"
        Amnesia.Table.wait([Users], 5000)

        res = Amnesia.Table.transform(Users, @users_v2, fn({Users, name, email}) ->
          {Users, name, email, 0}
        end)

        case res do
          :ok ->
            migrate()

          {:error, error} ->
            Logger.error "Users migration aborted: #{inspect error}"
            :error
        end

      @users_v2 ->
        Logger.info "Users is up-to-date"
        :error

      unknown_state ->
        Logger.error "Users table is in unknown state: #{inspect unknown_state}"
        :error
    end
  end
end

In the application module we initialize the schema, start database and create it on disk. Then we run our migrations and continue with starting child processes or terminate depending on the outcome.

In the migrate_users() function we get the current version of the database by requesting the set of the attributes. If we see it’s not current, we run the migration. If it is current, we return.

To migrate data Amnesia.Table.transform(table_name, new_attributes, transform_fun) function is used. The transform_fun is the function that accepts the current row and returns a new one. Note that the set of attributes in the new row must correspond to the new_attributes that you give as a second parameter to the call. In the example below, login_count field that comes last is initialized with 0.

Hope it made it a bit more clear.

tags: elixir - mnesia

Comments