Using Ecto and Sqlite3 with Nerves

One of the most common questions we answer in the Nerves help channels is how to store persistant data across reboots. Since the file system is read-only, the normal avenues usually will not work with Nerves.

There are several solutions that have yielded varying levels of success accross projects. Before we dive too deep into SQLite, lets take a look at the other options:

  • Key-Value storage such as the ever popular Persistant Storage

    • PROS: Super easy setup. Simple API.
    • CONS: May be too simple. No migrations, File size may be an issue.
  • DETS or Mnesia

    • PROS: Built into Erlang. Easy setup. Distributed.
    • CONS: No migration system. Can be difficult to maintain.
  • Full Databases such as PostgreSQL or MongoDB

    • PROS: Ecto adapters make these relatively easy, multi user, etc.
    • CONS: Require a large setup on Nerves - Custom system, configs, setup etc.

And depending on your use case, one or more of those might be more useful to you. But I’ve found in many cases a simple, local, non-clustered database is the perfect data storage mechanism for an embedded system like Nerves.

Application setup

Let’s walk through a quick example app to get us up and running with Nerves and Ecto + SQLite3.

mix nerves.new hello_db

First off, crack open the mix.exs file and add the :sqlite_ecto2 dependency to your list.

# ...
def deps do
  [
    {:sqlite_ecto2, "~> 2.3"}
  ]
end

Next, open your lib/hello_db/application.ex file and add this line:

# ...
children = [
  HelloDb.Repo
]

Then, open up our config.exs file to configure Ecto and our new adapter.

config :hello_db, HelloDb.Repo,
  adapter: Sqlite.Ecto2,
  database: "#{Mix.env}.sqlite3"

config :hello_db, ecto_repos: [HelloDb.Repo]

If you’ve used phoenix, before this will be straight-forward. adapter: Sqlite.Ecto2 tell Ecto to use the SQLite adapter we installed. database: "#{Mix.env}.Sqlite3" tells the adapter what the name of the file is that will house our database. We sort it by env for standard testing purposes.

Now if we do a

mix deps.get
iex -S mix

You’ll notice that in the root of your project you will have a dev.sqlite3 file. The keen eye will notice that when deployed to our Nerves device, SQLite will not be allowed to write to that directory because of the read-only filesystem.

That is relatively easy to solve. Back in our config.exs file uncomment this line:

# import_config "#{Mix.Project.config[:target]}.exs"

Then create a new file config/rpi0.exs (or whatever you plan on deploying to) and override the Ecto config:

config :hello_db, HelloDb.Repo,
  adapter: Sqlite.Ecto2,
  database: "/root/#{Mix.env}.sqlite3"

config :hello_db, ecto_repos: [HelloDb.Repo]

Notice the only thing we changed was the database field. This means that when deployed our database will be written to the read+write application data partition of Nerves.

Database Setup

Great! Now we have an embedded database! But it will need to be setup before runtime won’t it? If you come from Phoenix, you know about all of Ecto’s cool Mix Tasks. So lets do that.

mix ecto.create

You may notice this will only provision our database in our host environment, but when deployed to our Nerves device, we unfortunately don’t have the luxury of Mix Tasks. We are going to have to do something a little custom.

Note: There are a number of ways to possibly accomplish getting your database setup in Nerves, and this is by no means the only way.

Naturally we should just be able to run the Mix Tasks from our application code. Unfortunately this won’t work. The Ecto tasks rely on things that just aren’t available in our Nerves release. So we will have to implement them a little bit manually.

First make sure we have a migration. mix ecto.gen.migration add_some_stuff Edit this file accordingly. (leaving it empty is fine too.)

In our application.ex file again let’s add some functionality.

@otp_app Mix.Project.config[:app]
def start(_type, _args) do
  import Supervisor.Spec, warn: false
  :ok = setup_db!()
  children = [
    HelloDb.Repo
  ]

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

defp setup_db! do
  repos = Application.get_env(@otp_app, :ecto_repos)
  for repo <- repos do
    if Application.get_env(@otp_app, repo)[:adapter] == Sqlite.Ecto2 do
      setup_repo!(repo)
      migrate_repo!(repo)
    end
  end
  :ok
end

defp setup_repo!(repo) do
  db_file = Application.get_env(@otp_app, repo)[:database]
  unless File.exists?(db_file) do
    :ok = repo.__adapter__.storage_up(repo.config)
  end
end

defp migrate_repo!(repo) do
  opts = [all: true]
  {:ok, pid, apps} = Mix.Ecto.ensure_started(repo, opts)

  migrator = &Ecto.Migrator.run/4
  pool = repo.config[:pool]
  migrations_path = Path.join([:code.priv_dir(@otp_app) |> to_string, "repo", "migrations"])
  migrated =
    if function_exported?(pool, :unboxed_run, 2) do
      pool.unboxed_run(repo, fn -> migrator.(repo, migrations_path, :up, opts) end)
    else
      migrator.(repo, migrations_path, :up, opts)
    end

  pid && repo.stop(pid)
  Mix.Ecto.restart_apps_if_migrated(apps, migrated)
end

We can break that down a bit here:

The setup_repo!/1 was derived from the create mix task. It just checks for the database file’s existence, and creates it if the file does not exist.

The migrate_repo/1 function is a bit more interesting. We actually need to start the repo (and its pool), find the path to our migrations, then of course run the migrations, and finally restart everything. Luckily Mix.Ecto is available for us that does much of the hard work for us.

And there we have it, Your SQLite repo will be setup and migrated at application startup. Obviously this is a little bit more config than your average Elixir or even Phoenix set up, but it’s all fairly straight forward. Hopefully this gives a good jumping-off point to storing data on a Nerves project.

Bonus points

Obviously, we didn’t cover all the details that you’d need to think about when setting up a database on your embedded device. Here are a few more things to consider.

  • If you plan on having migrations, what will you do about failed migrations?

  • When and how do you drop the database/repo if at all?

  • Should migrations always be run? Maybe you want to hook into OTA updates, and only run them on an update. (With the above code, hey would be run on every boot.)

comments powered by Disqus