Triplex - a complete solution to multi-tenancy with PG schemas

I’ve spent hours trying to implement a custom type for these primary keys, to no avail, do you have an example? My problem is mostly around associations unable to resolve themselves with cast_assoc, preloading, etc. For now I’m going to leave these as default integer types, but keeping in mind that they won’t work in javascript. Do you have separate modules for encoding your models to JSON? Perhaps it makes sense to convert these to strings there? (since Ecto.Changeset.cast/2 will cast string id inputs with no problem?)

Here is the custom type I put together:

defmodule CustomTypes.EctoBigint do

  @behaviour Ecto.Type

  def type, do: :integer

  def cast(string)  when is_binary(string),   do: _to_integer(string)
  def cast(integer) when is_integer(integer), do: {:ok, integer}
  def cast(_),                                do: :error

  def load(integer) when is_integer(integer), do: _to_string(integer)

  def dump(string)  when is_binary(string),   do: _to_integer(string)
  def dump(integer) when is_integer(integer), do: {:ok, integer}
  def dump(_),                                do: :error

  defp _to_integer(string) do
    case Integer.parse(string) do
      {int, _} -> {:ok, int}
      :error   -> :error
    end
  end

  defp _to_string(integer) do
    {:ok, Integer.to_string(integer)}
  end

end

Use it in your Ecto schema like this:

...
alias CustomTypes.EctoBigint, as: Bigint
...

@primary_key false
@derive {Poison.Encoder, only: [:id, ...]}

schema "myschema" do
  # Tell Ecto to use Bigint as the type and that id the primary_key.
  # `read_after_writes: true` is needed to return the id on insert etc.
  field :id, Bigint, primary_key: true, read_after_writes: true
  ...
  # Tell Ecto to use the Bigint type for any relations as well
  belongs_to :other_schema, OtherSchema, type: Bigint
end

In the migration:

...
  @table "myschema"

  def up do
    create table(@table, primary_key: false) do
      add :id, :bigint, primary_key: true
      ...
      add :other_schema_id, references(:other_schema, type: :bigint)
    end

    flush()

    execute """
    ALTER TABLE #{prefix()}.#{@table} ALTER COLUMN id SET DEFAULT #{prefix()}.next_id()
    """
  end

  def down do
    drop table(@table)
  end
...

This is what I worked out and everything seemed to be working fine but I haven’t had time lately to work with it much since my initial testing so YMMV.

Sorry for the delay and I hope I didn’t forget anything as I’m pressed for time due to a recent family illness.

2 Likes

1.2.0-rc is out!

Hey guys! Quick update: we got a new release!

Despite not having huge changes or new features, we have incremented the minor version for some reasons. You can check these reasons as well as the changelog here: https://github.com/ateliware/triplex/releases/tag/v1.2.0-rc.0

But for those who don’t have time do get there, the biggest things that are coming out with this release are:

  • Plug as an optional dependency, so if you don’t use any plug on your app, triplex will not add this dependency on your project.
  • The support for using triplex on OTP releases, which I really didn’t know we had a problem with, but now, thanks to @dustinfarris, we do not have it anymore! PS: if anyone does need this correction on a 1.1 patch release, please let me know.
  • Docs and README improved.
  • Refactors on plug configs and method return values.

Hope you’re liking to use it.

8 Likes

Hi @kelvinst
First of all, thanks for sharing! I am rather new to Elixir and Phoenix and I am currently working on a project where I would need multi tenancy. It seems like your work fits perfectly to my use-case but when implementing it, I was wondering if there is any possibility so I don’t have to pass the tenant to each Repo function from the controller. I am already using your Subdomain plug, so is there any possibility to directly use the subdomain that is extracted by your plugin as a prefix? Or is this even possible already?

1 Like

There are actually some ways to do that, but most of them are not a good practice at all. When I first started triplex, it was the default behaviour: we were saving the “current_tenant” to the process dict and using it to apply the prefix to all your Repo calls, but then we’ve noticed a lot of problems with that practice and concurrency.

It might seem a little bit unDRY the way it is now, but we decided to be hands-off and explicit over implicit on this. So, if you want, you can manually make exactly what we were doing, but be prepared to have some problems with concurrency anytime.

Well, the only thing these plugs do is to extract the tenant from somewhere and to save it to your Plug.Conn assigns. The docs are explaining how they work.

The method Triplex.to_prefix does the job of transforming the tenant on its corresponding prefix.

1 Like

Ok, this sounds reasonable therefore I will use your library in the explicit way. Thanks for your fast answer!

1 Like

Do you support multiple databases or all schemas have to reside in the same database?

I think Triplex will use the repos defined in your app, so you can use multiple databases. I found this in the docs:

The repository must be set under :ecto_repos in the
current app configuration or given via the -r option.
triplex/lib/mix/tasks/triplex.migrate.ex at master · ateliware/triplex · GitHub

What do you mean by multiple databases? Separate the schemas in multiple separated databases?

If so, you would have to have one Ecto.Repo for each database. And in this case, every method in triplex that executes something on your database receives an optional parameter for the repo at the end. If this parameter is omitted, it will get the repo from what’s configured with config :triplex, repo: MyApp.Repo.

As for migrations, @mgiacomini answer is totally right, we use what’s configured in config MyApp, ecto_repos: [MyApp.Repo], so migrations run by default on every repo listed in this configuration.

It would be interesting to test this against the open source Citus. Citus already lists compatibility with a Django and a Rails multitenant plug-in.

If it worked as intended it could provided some more exposure for the plugin as well if they’d list it in their docs.

1 Like

Yep

Thanx got it

This looks like an awesome library and is exactly what I need. I do have some questions:

  • Does anyone have actual production experience with using separate schemas in PostgresQL for multi-tenancy in a SaaS application (either using Triplex or not)? Have you found that this approach scales well or not eg. for thousands or tens of thousands of tenants or more? (Both in terms of performance and ease of maintenance.)

  • Triplex looks like it will neatly solve the multi-tenancy part of the problem. However, what about sharding to multiple PostgresQL physical server instances to distribute load? How would one go about that together with using Triplex?

1 Like

Hey @chanon thanks for all the :purple_heart:!

So here it goes:

Yes, we used it, but didn’t found too many problems, except for some bad stuff we did on migrations (but was our fault). But the creators of apartment (the same lib in ruby world) found some problems with a big database: here are their thoughts, and we discussed about it here in this topic.

I personally don’t have any kind of experience with this yet. Would love to see some other people here sharing their thoughts!

3 Likes

@kelvinst Thanks for the answers! I’m going to go ahead and try using Triplex.

2 Likes

I’m here to announce the long awaited new version for Triplex!

Actually, it’s an RC for the new version. You know, we need to test it well before releasing it out in the wild.

Anyways, this version will come with the support for two new things: Ecto 3 and MySQL!

The details are here in the release notice: https://github.com/ateliware/triplex/releases/tag/v1.3.0-rc.0

Also, refer to the release notice for how to upgrade, since there are some breaking changes on Ecto 3 migrations that affected Triplex.create inside Repo.transactions.

With no further ado, we would be very grateful if you could test it out on your projects and let us know with github issues if any problem occurs!

Thank you all!

7 Likes

We just released a new release candidate 1.3.0-rc.1 because there was a bug that would happen for everyone that is not using both MySQL and Postgres, so pretty much everyone. Please update to get the fix. Thanks!

1 Like

Heeey! Long time no see, but I’m here to announce 1.3.0 (not rc, the official one) is out! You can check the list of stuff on the release notice, but for those that have no time, the big thing on this release was the support to Ecto 3.

Hope that the lib is still useful for all of you and we are always open to contributions, but please do not be sad if we take some time to answer, there are not too many people on the core team for it and we are very busy lately.

Thanks!

9 Likes

I was just wondering if this works with Absinthe? I’m thinking of using Mysql as well.

2 Likes

Why wouldn’t work? Inside your resolver just use the prefix option as usual :). With dataloader you can use this:
Dataloader.Ecto.new(MyApp.Repo, repo_opts: [prefix: Triplex.to_prefix("some-tenant")])

2 Likes

No reason it wouldn’t work. I’m just beginning so I wanted to make sure. Thank you!

2 Likes