Postgres Partitions in Elixir Migration


Postgres 11 has a nifty feature around partitions. When a partition exists for a range of values, when you insert into the parent table it’ll get routed to the correct partition. When you update that record’s partition key it will get moved to the correct partition. A default partition feature exists as well so that if you do try to insert something that doesn’t belong in any available partitions, it will be put there instead.

I wanted to use this feature to track transactions in a game economy. Where all transactions were stored with their transacted date as the partition key.

Here is the Wallet model.

defmodule Game.Wallets.Wallet do
  alias Game.Wallets.Transaction
  alias Game.Wallets.Wallet

  use Ecto.Schema

  import Ecto.Changeset

  @type t :: %__MODULE__{
          id: Ecto.UUID.t(),
          name: String.t(),
          balance: Decimal.t(),
          locked_at: DateTime.t(),
          deleted_at: DateTime.t(),
          updated_at: DateTime.t(),
          inserted_at: DateTime.t()
        }
  @primary_key {:id, :binary_id, autogenerate: false, read_after_writes: true}
  @foreign_key_type :binary_id
  schema("wallets") do
    field(:name, :string)

    field(:balance, :decimal, default: 0.0)

    field(:locked_at, :utc_datetime)
    field(:deleted_at, :utc_datetime)
    field(:updated_at, :utc_datetime)
    field(:inserted_at, :utc_datetime)

    has_many(:transactions, Transaction)
  end
end

Here is the Transaction model.

defmodule Game.Wallets.Transaction do
  alias Game.Wallets.Wallet

  use Ecto.Schema

  import Ecto.Changeset

  @type t :: %__MODULE__{
          id: Ecto.UUID.t(),
          occurred_at: DateTime.t(),
          credit: Decimal.t(),
          debit: Decimal.t(),
          description: String.t(),
          wallet_id: Ecto.UUID.t()
        }
  @primary_key {:id, :binary_id, autogenerate: false, read_after_writes: true}
  @foreign_key_type :binary_id
  schema("transactions") do
    field(:occurred_at, :utc_datetime)

    field(:credit, :decimal, default: 0.0)
    field(:debit, :decimal, default: 0.0)

    field(:description, :string)

    belongs_to(:wallet, Wallet, type: :binary_id)
  end
end

A fairly simple model, where I expect there to be millions of transactions. Something that can not live well on a single partition and be performant. So we need to make a migration that can handle this.

defmodule Game.Repo.Migrations.CreateWalletsAndTransactions do
  use Ecto.Migration

  def up do
    execute """
    CREATE TABLE wallets (
      id          UUID NOT NULL DEFAULT uuid_generate_v4(),
      name        TEXT NOT NULL,
      balance     DECIMAL(20, 2) NOT NULL DEFAULT 0.0,
      inserted_at TIMESTAMP NOT NULL DEFAULT now(),
      updated_at  TIMESTAMP NOT NULL DEFAULT now(),
      locked_at   TIMESTAMP,
      deleted_at  TIMESTAMP,
      PRIMARY KEY(id),
      CONSTRAINT wallets_balance_ck CHECK(balance >= 0)
    )
    """
  end

  def down do
    execute "DROP TABLE wallets CASCADE"
  end
end

We need to create the base paritition and default partition.

execute """
CREATE TABLE transactions (
  id          UUID           NOT NULL DEFAULT uuid_generate_v4(),
  wallet_id   UUID           NOT NULL REFERENCES wallets(id),
  occurred_at TIMESTAMP      NOT NULL DEFAULT now(),
  credit      DECIMAL(20, 2) NOT NULL DEFAULT 0.0,
  debit       DECIMAL(20, 2) NOT NULL DEFAULT 0.0,
  description TEXT
) PARTITION BY RANGE (occurred_at)
"""

Then we need to create the default partition.

execute "CREATE TABLE transactions_default PARTITION OF transactions DEFAULT"

Now the fun part is I needed a bunch of partitions created but didn’t want to type them all out by hand / copy paste.

start_date = beginning_of_month(~D[2019-04-01])

for months <- 0..47 do
  create_partition("transactions", calculate_next_month(start_date, months))
end

The #create_partition/2, #beginning_of_month/1, and #calculate_next_month/2 are defined as follows.

defp create_partition(table, date) do
  start_date = date

  stop_date =
    date
    |> Date.add(Date.days_in_month(date))

  month =
    start_date.month
    |> Integer.to_string()
    |> String.pad_leading(2, "0")

  execute """
  CREATE TABLE #{table}_p#{start_date.year}_#{month}
  PARTITION OF #{table} FOR VALUES
  FROM ('#{start_date}')
  TO ('#{stop_date}')
  """
end
defp beginning_of_month(date) do
  if date.day == 1 do
    date
  else
    Date.add(date, -(date.day - 1))
  end
end
defp calculate_next_month(date, 0), do: date

defp calculate_next_month(date, months) do
  next = Date.add(date, Date.days_in_month(date))
  calculate_next_month(next, months - 1)
end

I defined these methods in the Game.Repo.Migrations.CreateWalletsAndTransactions but definitely will extract these into a utility function to be used later.

Feel free to use it or manipulate it how ever. If you come up with a better solution I’d really like to see it.