Ash Schema-based multi-tenancy - don’t see any schemas being created

Hey All!

I’m really new to Ash but I think it’s got a lot of potential to save me a ton of time so I’m working through this for a proof-of-concept / MVP I’m creating.

TL;DR is I’m trying to create an app and a multi-tenant database using schema-isolated multi-tenants approach (one schema per organization) - I’ve got it creating tables but I don’t see any schemas being created, so I just have one accounts table and I should be seeing multiple schemas (one per org) with an accounts table in each schema.

Prior to trying to add in multi-tenancy, I was creating the tables “like normal” and was able to work through the relationship creating and attributes creation. I got that all set up in postgres.

When I started switching the resources to be multi-tenant, I noticed that the multi-tenant migrations started being created during codegen (i.e. mix ash.codegen add_multitenancy_to_remaining ) which seemed good and promising, but that’s where I got stuck because I noticed no multiple schemas were getting created. Also, I tried creating an entry in the organizations table to see if that would trigger a new schema. It didn’t. Then I ran mix ecto.migrate again after the new organizations record was added to the database, but no new schemas were added to the database.

and when I do mix ecto.drop, mix ecto.create and mix ecto.migrate --migrations-path priv/repo/tenant_migrations
then I get all the same issues where I only have one accounts table and not multiple schemas - though I’m NOT seeing an organizations table (my only non-multi-tenant resource right now).

When I do normal mix ecto.drop, mix ecto.create and mix ecto.migrate , then I get the organizations table but still not multiple schemas.

Do I need to redo all my migration code with the codegen from scratch? Do you think something got messed up along the way because the resources started out as being non-multi-tenant? Or am I looking at the issue all wrong? if so, please enlighten me

So here’s the code for inspection :

I marked all of them as multi-tenant like this (full code)

defmodule MyApp.MyDomain.Account do
  use Ash.Resource,
    domain: MyApp.MyDomain,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "accounts"
    repo MyApp.Repo
  end

  multitenancy do
    strategy :context
  end


  actions do
    defaults [:read]

    create :signup do
      accept [:username, :email]
    end
  end

  attributes do
    uuid_primary_key :id

    attribute :username, :string do
      allow_nil? false
      public? true
    end

    attribute :email, :string do
      allow_nil? true
      public? true
    end
  end

end

and I created an Organization resource which I believe is set to be non-multi-tenant because there should just be one table of tenants (full code)

defmodule MyApp.MyDomain.Organization do
  use Ash.Resource,
    domain: MyApp.MyDomain,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "organizations"
    repo MyApp.Repo
  end

  actions do
    defaults [:read]

    create :create do
      accept [:name]
    end
  end

  attributes do
    uuid_primary_key :id

    attribute :name, :string do
      allow_nil? false
      public? true
    end
  end

  postgres do
    manage_tenant do
      template ["org_", :id]
    end
  end


  defimpl Ash.ToTenant do
    def to_tenant(%{id: id}, resource) do
      if Ash.Resource.Info.data_layer(resource) == AshPostgres.DataLayer
        && Ash.Resource.Info.multitenancy_strategy(resource) == :context do
        "org_#{id}"
      else
        id
      end
    end
  end
end

Try using mix ash.migrate && mix ash.migrate —tenants instead of mix ecto.migrate

Oh cool - I did not know those existed

I ran those and I got a new error

MyApp.Repo.all_tenants/0` was called, but was not defined. In order to migrate tenants, you must define this function.
For example, you might say:

  def all_tenants do
    for org <- MyApp.Accounts.all_organizations!() do
      org.schema
    end
  end

And I was able to make the needed change and I updated my repo module to have this method

  def all_tenants do
    for org <- Ash.read!(MyApp.MyDomain.Organization) do
      "org_#{org.id}"
    end
  end

And from there I was able to do ash.migrate --tenants with NO organizations in the db (still no schemas)
and then I added a new organization and did ash.migrate --tenants and I got a new error:

18:03:47.505 [error] Could not create schema migrations table. This error usually happens due to the following:
...
  * The database does not exist
  * The "schema_migrations" table, which Ecto uses for managing
    migrations, was defined by another library
  * There is a deadlock while migrating (such as using concurrent
    indexes with a migration_lock)
...
** (Postgrex.Error) ERROR 3F000 (invalid_schema_name) schema "org_3b11ce0a-2c2f-47c1-a762-ed743d56d194" does not exist
    (ecto_sql 3.12.1) lib/ecto/adapters/sql.ex:1096: Ecto.Adapters.SQL.raise_sql_call_error/1

I found that updating the code to replace the dashes with underscores in the org id helped remove the error.

  def all_tenants do
    for org <- Ash.read!(MyApp.MyDomain.Organization) do
      "org_#{org.id |> Organization.org_id_to_schema_id}"
    end
  end

So that’s good.

So now I’m able to run ash.migrate with absolutely no errors but I still see no additional schemas in my DB

me@mycomputer my_app % mix ash.migrate && mix ash.migrate —-tenants
Getting extensions in current project...
Running migration for AshPostgres.DataLayer...

19:07:37.039 [info] Migrations already up
Getting extensions in current project...
Running migration for AshPostgres.DataLayer...

19:07:39.177 [info] Migrations already up

List of db schemas

my_app_dev=# \dn
      List of schemas
  Name  |       Owner       
--------+-------------------
 public | pg_database_owner
(1 row)

Any additional wisdom? Should I re-create the migrations?
Am I missing something?

Also I’m open to doing things differently. I just know my app will be multi-tenant and so I was hoping for a nice way to handle that with Ash. I read in the docs that :attribute strategy can work but it made it sound like it moght not be ideal and so schema separation appealed to me.

Right, so new schemas aren’t created for existing organizations when you run migrate --tenants. New schemas are created when organizations are created. Additionally, you will need to do something to adjust the templated schema in manage_template.

My suggestion is to add a global change that happens on creates that sets the schema name. You can use the id for that if you like, as you are now, and do the replacement in the change.

change fn changeset, _ -> 
  if changeset.action.type == :create do
    schema_name = "org_" <> String.replace(Ash.Changeset.get_attribute(changeset, :id), "-", "_")
    Ash.Changeset.change_attribute(changeset, :schema_name, schema_name)
  else
    changeset
  end
end

Then, set the template like so:

    manage_tenant do
      template [:schema_name]
    end

And then use that schema_name when listing tenants in the repo.

Then, new schemas should be created whenever you create an organization.

Ah perfect thank you!

I was using SQL CLI inserts before to add organizations but once I started using the ash elixir code to add the organizations, then the magic worked like you said and I got the new schema and tables being created.

I did get the ID and schema name stuff working with more of your advice above so thank you. I’ll post the code below

Last question: Should I be seeing tables for my multi-tenant resources in my public schema? I see the organizations table there in addition to all the other tables; I was only expecting to see the organizations table there because all my other resources are tenant-specific resources. Are tenant-specific tables supposed to be created in the plublic schema? And if so, why?

I have confirmed that the organizations table is not present in the tenant-specific schemas.

defmodule MyApp.MyDomain.Organization do
  use Ash.Resource,
    domain: MyApp.MyDomain,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "organizations"
    repo MyApp.Repo
  end

  actions do
    defaults [:read]

    create :create do
      accept [:name]
    end
  end

  attributes do
    uuid_primary_key :id

    attribute :name, :string do
      allow_nil? false
      public? true
    end

    attribute :schema_name, :string do
      allow_nil? true
      public? true
    end
  end

  postgres do
    manage_tenant do
      template [:schema_name]
    end
  end

  changes do
    # global
    change fn changeset, _ ->
      if changeset.action.type == :create do
        myId = case Ash.Changeset.get_attribute(changeset, :id) do
          nil -> Ecto.UUID.generate()
          specifiedId -> specifiedId
          _ -> Ecto.UUID.generate()
        end
        schema_name = org_id_to_schema_id(myId)
        Ash.Changeset.change_attribute(changeset, :schema_name, schema_name)
      else
        changeset
      end
    end
  end


  defimpl Ash.ToTenant do
    def to_tenant(%{id: id, schema_name: schema_name}, resource) do
      if Ash.Resource.Info.data_layer(resource) == AshPostgres.DataLayer
        && Ash.Resource.Info.multitenancy_strategy(resource) == :context do
        schema_name
      else
        id # I think this case will never happen
      end
    end
  end

  @spec org_id_to_schema_id(binary()) :: binary()
  def org_id_to_schema_id(org_id) do
    "org_" <> (org_id |> String.replace("-","_"))
  end
end

Unless you have global? true configured, then no you should only see your tenant resources in each schema, not the public schema. Then with your current setup only organizations should be in the public schema.

Did you reset your database? Could it be something from an earlier time you set things up?

Hey, good news:
After re-generating the migrations, everything works as expected. So that’s good!

Thanks again for the help from before!

2 Likes