From c8252301960350ce41eca02ed766a7298bd00bba Mon Sep 17 00:00:00 2001 From: Conor Sinclair Date: Sun, 18 Oct 2026 13:07:42 +0200 Subject: [PATCH 1/4] improvement: open one database per tenant under a supervisor the repo starts `use AshSqlite.Repo` now overrides `child_spec/1`. A repo configured with `tenants:` starts `AshSqlite.MultiTenancy.Supervisor`, with a Registry, a DynamicSupervisor of `AshSqlite.MultiTenancy.Tenant` processes, and the base repo instance when `database:` is set. A repo without `tenants:` gets Ecto's child spec unchanged. The supervisor is `:rest_for_one` with the Registry first, so a Registry restart also restarts the DynamicSupervisor and stops every tenant it no longer knows about. `AshSqlite.MultiTenancy.tenant_repo/3` is the callback body a tenant repo delegates to. Each tenant process opens its file in `handle_continue/2`, runs the tenant migrations compiled once at supervisor start, and runs `SELECT 1` before serving its repo instance. --- lib/multi_tenancy.ex | 46 ++++++ lib/multi_tenancy/supervisor.ex | 86 ++++++++++ lib/multi_tenancy/tenant.ex | 108 +++++++++++++ lib/repo.ex | 5 + .../20260101000000_create_posts.exs | 14 ++ test/multi_tenancy/multi_tenancy_test.exs | 153 ++++++++++++++++++ test/support/domain.ex | 1 + test/support/multi_tenancy_repo.ex | 17 ++ test/support/resources/multi_tenancy_post.ex | 30 ++++ 9 files changed, 460 insertions(+) create mode 100644 lib/multi_tenancy.ex create mode 100644 lib/multi_tenancy/supervisor.ex create mode 100644 lib/multi_tenancy/tenant.ex create mode 100644 test/multi_tenancy/migrations/20260101000000_create_posts.exs create mode 100644 test/multi_tenancy/multi_tenancy_test.exs create mode 100644 test/support/multi_tenancy_repo.ex create mode 100644 test/support/resources/multi_tenancy_post.ex diff --git a/lib/multi_tenancy.ex b/lib/multi_tenancy.ex new file mode 100644 index 0000000..c864018 --- /dev/null +++ b/lib/multi_tenancy.ex @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancy do + @moduledoc """ + A `c:AshSqlite.Repo.tenant_repo/2` implementation that gives each tenant its own database file. + + Configure the repo with `config :my_app, MyApp.Repo, tenants: [dir: "...", migrations_path: "...", max_children: ...]` + and implement the callback with `def tenant_repo(tenant, context), do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context)`. + A tenant's file is opened and migrated on first use. + """ + + alias AshSqlite.MultiTenancy.{Supervisor, Tenant} + + @doc "The repo instance serving `tenant`, opening its database if it is not open." + @spec tenant_repo(module(), term(), map()) :: pid() + def tenant_repo(repo, tenant, _context) do + registry = Supervisor.registry(repo) + + if is_nil(Process.whereis(registry)) do + raise ArgumentError, """ + #{inspect(repo)} has no tenant supervisor running, so tenant #{inspect(tenant)} \ + has no database to open. Configure the `tenants:` key and start the repo: + + config :my_app, #{inspect(repo)}, tenants: [dir: "priv/tenants"] + """ + end + + pid = + case Registry.lookup(registry, tenant) do + [{pid, _}] -> pid + [] -> start_tenant(repo, tenant) + end + + Tenant.instance(pid, tenant) + end + + defp start_tenant(repo, tenant) do + case DynamicSupervisor.start_child(Supervisor.tenants(repo), {Tenant, {repo, tenant}}) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + {:error, reason} -> raise "could not start tenant #{inspect(tenant)}: #{inspect(reason)}" + end + end +end diff --git a/lib/multi_tenancy/supervisor.ex b/lib/multi_tenancy/supervisor.ex new file mode 100644 index 0000000..dc2a566 --- /dev/null +++ b/lib/multi_tenancy/supervisor.ex @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancy.Supervisor do + @moduledoc false + use Supervisor + + def child_spec(repo, opts, ecto_child_spec) do + config = Keyword.merge(repo.config(), opts) + + case Keyword.fetch(config, :tenants) do + :error -> + ecto_child_spec + + {:ok, tenants} -> + if !AshSqlite.DataLayer.tenant_repo?(repo) do + raise ArgumentError, """ + #{inspect(repo)} is configured with `tenants:`, but does not implement \ + `c:AshSqlite.Repo.tenant_repo/2`. Add this to the repo: + + def tenant_repo(tenant, context), do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context) + """ + end + + base_repo = if config[:database], do: [ecto_child_spec], else: [] + + %{ + id: repo, + start: {__MODULE__, :start_link, [{repo, tenants, base_repo}]}, + type: :supervisor + } + end + end + + def start_link({repo, _tenants, _base_repo} = arg) do + Supervisor.start_link(__MODULE__, arg, name: Module.concat(repo, TenantSupervisor)) + end + + def registry(repo), do: Module.concat(repo, TenantRegistry) + def tenants(repo), do: Module.concat(repo, Tenants) + + @impl true + def init({repo, tenants, base_repo}) do + migrations = compile_migrations(tenants[:migrations_path]) + + children = + [ + {Registry, + keys: :unique, + name: registry(repo), + meta: [migrations: migrations, dir: Keyword.fetch!(tenants, :dir)]}, + {DynamicSupervisor, + name: tenants(repo), max_children: Keyword.get(tenants, :max_children, :infinity)} + ] ++ base_repo + + # A restarted Registry has forgotten every tenant, so the tenants started after it + # must go too, or a second process could open the same file. + Supervisor.init(children, strategy: :rest_for_one) + end + + defp compile_migrations(nil), do: [] + + # Mirrors `Ecto.Migrator`'s naming rules: `_.exs`. + defp compile_migrations(path) do + [path, "**", "*.exs"] + |> Path.join() + |> Path.wildcard() + |> Enum.flat_map(fn file -> + case Integer.parse(Path.basename(file)) do + {version, "_" <> _} -> [{version, migration_module!(file)}] + _ -> [] + end + end) + |> Enum.sort() + end + + defp migration_module!(file) do + file + |> Code.compile_file() + |> Enum.map(&elem(&1, 0)) + |> Enum.find(&function_exported?(&1, :__migration__, 0)) || + raise Ecto.MigrationError, + "file #{Path.relative_to_cwd(file)} does not define an Ecto.Migration" + end +end diff --git a/lib/multi_tenancy/tenant.ex b/lib/multi_tenancy/tenant.ex new file mode 100644 index 0000000..1d21b68 --- /dev/null +++ b/lib/multi_tenancy/tenant.ex @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancy.Tenant do + @moduledoc """ + The process that owns one tenant's database: it opens and migrates the file, and its repo instance lives and dies with it. + """ + use GenServer, restart: :temporary + + @unreserved ~c"abcdefghijklmnopqrstuvwxyz0123456789-_." + + # 255 is the usual per-name limit. `-shm` is the longest suffix SQLite adds to a + # database's name, and `.db` is ours. + @max_encoded_bytes 255 - byte_size(".db") - byte_size("-shm") + + def start_link({repo, tenant}) do + GenServer.start_link(__MODULE__, {repo, tenant}, + name: {:via, Registry, {AshSqlite.MultiTenancy.Supervisor.registry(repo), tenant}} + ) + end + + @doc "The repo instance of the tenant process `pid`, waiting for its database to open." + @spec instance(pid(), term()) :: pid() + def instance(pid, tenant) do + GenServer.call(pid, :instance, :infinity) + catch + :exit, reason -> + raise "could not open the database of tenant #{inspect(tenant)}: #{Exception.format_exit(reason)}" + end + + @impl true + # `DynamicSupervisor.start_child/2` runs `init/1` inside the supervisor, so opening + # here would queue every tenant behind every other tenant's open. + def init({repo, tenant}), do: {:ok, {repo, tenant}, {:continue, :open}} + + @impl true + def handle_continue(:open, {repo, tenant}) do + {:ok, meta} = registry_meta(repo) + dir = Keyword.fetch!(meta, :dir) + File.mkdir_p!(dir) + + {:ok, instance} = + repo.start_link( + name: nil, + database: Path.join(dir, encode(tenant) <> ".db"), + tenant: tenant + ) + + Ecto.Migrator.run(repo, Keyword.fetch!(meta, :migrations), :up, + all: true, + dynamic_repo: instance, + log: false + ) + + Ecto.Adapters.SQL.query!(instance, "SELECT 1", []) + + {:noreply, %{repo: repo, tenant: tenant, instance: instance}} + end + + @impl true + def handle_call(:instance, _from, state), do: {:reply, state.instance, state} + + defp registry_meta(repo) do + registry = AshSqlite.MultiTenancy.Supervisor.registry(repo) + + with {:ok, migrations} <- Registry.meta(registry, :migrations), + {:ok, dir} <- Registry.meta(registry, :dir) do + {:ok, migrations: migrations, dir: dir} + end + end + + # Escapes rather than sanitises, so no two tenants share a file and no tenant names a + # path outside `dir`. + defp encode(tenant) when is_binary(tenant) and byte_size(tenant) > 0 do + encoded = tenant |> :binary.bin_to_list() |> Enum.map_join(&encode_byte/1) + + if byte_size(encoded) > @max_encoded_bytes do + raise ArgumentError, """ + tenant #{inspect(tenant)} encodes to #{byte_size(encoded)} bytes, and a \ + database name can be at most #{@max_encoded_bytes}. Use a shorter identifier, \ + such as a slug or a UUID, as the tenant. + """ + end + + encoded + end + + defp encode(tenant) do + raise ArgumentError, """ + expected a non-empty binary tenant, got: #{inspect(tenant)} + + A tenant names a database file, and stringifying another term here would let two \ + tenants that Ash tells apart name one file. Convert it where you set the tenant. + """ + end + + defp encode_byte(byte) when byte in @unreserved, do: <> + + defp encode_byte(byte) do + <> = <> + <> + end + + # Lowercase, so two encodings never differ only by case. + defp hex(nibble) when nibble < 10, do: ?0 + nibble + defp hex(nibble), do: ?a + nibble - 10 +end diff --git a/lib/repo.ex b/lib/repo.ex index 821e967..9e2fa53 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -65,11 +65,16 @@ defmodule AshSqlite.Repo do adapter: adapter, otp_app: otp_app + defoverridable child_spec: 1 + @behaviour AshSqlite.Repo @before_compile AshSqlite.Repo.BeforeCompile defoverridable insert: 2, insert: 1, insert!: 2, insert!: 1 + def child_spec(opts), + do: AshSqlite.MultiTenancy.Supervisor.child_spec(__MODULE__, opts, super(opts)) + def installed_extensions, do: [] def migrations_path, do: nil def write_transactions?, do: false diff --git a/test/multi_tenancy/migrations/20260101000000_create_posts.exs b/test/multi_tenancy/migrations/20260101000000_create_posts.exs new file mode 100644 index 0000000..bf152a0 --- /dev/null +++ b/test/multi_tenancy/migrations/20260101000000_create_posts.exs @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.MultiTenancyMigrations.CreatePosts do + use Ecto.Migration + + def change do + create table(:posts, primary_key: false) do + add(:id, :binary_id, primary_key: true) + add(:title, :text) + end + end +end diff --git a/test/multi_tenancy/multi_tenancy_test.exs b/test/multi_tenancy/multi_tenancy_test.exs new file mode 100644 index 0000000..2cabf77 --- /dev/null +++ b/test/multi_tenancy/multi_tenancy_test.exs @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancyTest do + @moduledoc """ + Tenants opened by `AshSqlite.MultiTenancy`, checked against the files on disk. + """ + use ExUnit.Case, async: false + + @moduletag :capture_log + + alias AshSqlite.Test.{MultiTenancyPost, MultiTenancyRepo} + + @migrations Path.expand("migrations", __DIR__) + + defmodule NoCallbackRepo do + @moduledoc false + use AshSqlite.Repo, otp_app: :ash_sqlite + + def min_sqlite_version, do: %Version{major: 3, minor: 38, patch: 0} + end + + setup do + dir = Path.join(System.tmp_dir!(), "ash_sqlite_mt_#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(dir) end) + %{dir: dir} + end + + defp start_repo(dir, tenants \\ []) do + start_supervised!( + {MultiTenancyRepo, + tenants: Keyword.merge([dir: dir, migrations_path: @migrations], tenants)} + ) + end + + defp create!(tenant, title) do + MultiTenancyPost + |> Ash.Changeset.for_create(:create, %{title: title}, tenant: tenant) + |> Ash.create!() + end + + defp titles(tenant) do + MultiTenancyPost |> Ash.read!(tenant: tenant) |> Enum.map(& &1.title) |> Enum.sort() + end + + defp titles_in_file(path) do + {:ok, db} = Exqlite.Sqlite3.open(path) + {:ok, stmt} = Exqlite.Sqlite3.prepare(db, "SELECT title FROM posts ORDER BY title") + {:ok, rows} = Exqlite.Sqlite3.fetch_all(db, stmt) + :ok = Exqlite.Sqlite3.close(db) + List.flatten(rows) + end + + defp db_files(dir), + do: + dir + |> File.ls!() + |> Enum.map(&Path.join(dir, &1)) + |> Enum.filter(&String.ends_with?(&1, ".db")) + + test "a repo without tenants: gets Ecto's child spec" do + assert AshSqlite.TestRepo.child_spec([]) == %{ + id: AshSqlite.TestRepo, + start: {AshSqlite.TestRepo, :start_link, [[]]}, + type: :supervisor + } + end + + test "tenants: without tenant_repo/2 raises at boot", %{dir: dir} do + assert_raise ArgumentError, + ~r/`tenants:`.*\n.*AshSqlite.MultiTenancy.tenant_repo\(__MODULE__/s, + fn -> + NoCallbackRepo.child_spec(tenants: [dir: dir]) + end + end + + test "using the helper with no tenants: config raises a named error" do + assert_raise Ash.Error.Unknown, ~r/no tenant supervisor running.*config :my_app/s, fn -> + create!("acme", "one") + end + end + + test "an Ash create and read go to each tenant's own file", %{dir: dir} do + start_repo(dir) + + create!("acme", "a1") + create!("globex", "g1") + + assert titles("acme") == ["a1"] + assert titles("globex") == ["g1"] + assert titles_in_file(Path.join(dir, "acme.db")) == ["a1"] + assert titles_in_file(Path.join(dir, "globex.db")) == ["g1"] + end + + test "tenants that sanitising would merge get different files", %{dir: dir} do + start_repo(dir) + + create!("a:b", "colon") + create!("a_b", "underscore") + create!("../../etc/x", "traversal") + + files = db_files(dir) + assert length(files) == 3 + assert Enum.all?(files, &(Path.dirname(&1) == dir)) + assert titles("a:b") == ["colon"] + assert titles("a_b") == ["underscore"] + end + + test "concurrent first use of a cold tenant starts one process and one instance", %{dir: dir} do + start_repo(dir) + + instances = + 1..20 + |> Enum.map(fn _ -> + Task.async(fn -> MultiTenancyRepo.tenant_repo("cold", %{}) end) + end) + |> Task.await_many() + + assert [_] = Enum.uniq(instances) + assert [{_, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "cold") + assert %{active: 1} = DynamicSupervisor.count_children(MultiTenancyRepo.Tenants) + end + + test "a migration that raises fails the open", %{dir: dir} do + migrations = Path.join(dir, "migrations") + File.mkdir_p!(migrations) + + File.write!(Path.join(migrations, "20260101000000_boom.exs"), """ + defmodule AshSqlite.Test.MultiTenancyMigrations.Boom do + use Ecto.Migration + def up, do: raise("boom") + end + """) + + start_repo(Path.join(dir, "tenants"), migrations_path: migrations) + + assert_raise RuntimeError, ~r/could not open the database of tenant "acme".*boom/s, fn -> + MultiTenancyRepo.tenant_repo("acme", %{}) + end + end + + test "killing the repo supervisor takes tenants and instances with it", %{dir: dir} do + supervisor = start_repo(dir) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + refs = Enum.map([supervisor, tenant, instance], &Process.monitor/1) + Process.exit(supervisor, :kill) + + for ref <- refs, do: assert_receive({:DOWN, ^ref, _, _, _}) + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index e5d8914..185c7c2 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -21,6 +21,7 @@ defmodule AshSqlite.Test.Domain do resource(AshSqlite.Test.TransactionalAccount) resource(AshSqlite.Test.NamedFnRepoAccount) resource(AshSqlite.Test.TenantedPost) + resource(AshSqlite.Test.MultiTenancyPost) resource(AshSqlite.Test.Organization) resource(AshSqlite.Test.Manager) resource(AshSqlite.Test.Device) diff --git a/test/support/multi_tenancy_repo.ex b/test/support/multi_tenancy_repo.ex new file mode 100644 index 0000000..97d64f4 --- /dev/null +++ b/test/support/multi_tenancy_repo.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.MultiTenancyRepo do + @moduledoc """ + A tenant repo whose tenants are managed by `AshSqlite.MultiTenancy`. + """ + use AshSqlite.Repo, otp_app: :ash_sqlite + + def min_sqlite_version, do: %Version{major: 3, minor: 38, patch: 0} + + def write_transactions?, do: true + + def tenant_repo(tenant, context), + do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context) +end diff --git a/test/support/resources/multi_tenancy_post.ex b/test/support/resources/multi_tenancy_post.ex new file mode 100644 index 0000000..38bbc98 --- /dev/null +++ b/test/support/resources/multi_tenancy_post.ex @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.MultiTenancyPost do + @moduledoc """ + A `strategy :context` resource on a repo whose tenants `AshSqlite.MultiTenancy` opens. + """ + use Ash.Resource, domain: AshSqlite.Test.Domain, data_layer: AshSqlite.DataLayer + + actions do + default_accept(:*) + defaults([:create, :read]) + end + + multitenancy do + strategy(:context) + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + end + + sqlite do + table("posts") + repo(AshSqlite.Test.MultiTenancyRepo) + migrate?(false) + end +end From 46c943fc02a128d8e41b47e0d0289eedd0d18957 Mon Sep 17 00:00:00 2001 From: Conor Sinclair Date: Sun, 18 Oct 2026 20:50:22 +0200 Subject: [PATCH 2/4] fix: stop a tenant's repo instance before the tenant leaves the Registry - `AshSqlite.MultiTenancy.Tenant` traps exits and stops its instance in `terminate/2`, so `DynamicSupervisor.terminate_child/2` returns only once the file is closed. An instance that crashes stops its tenant. - `AshSqlite.MultiTenancy.tenant_repo/3` retries when it reaches a tenant that is stopping or already gone, and raises only for a failed open. - The tenant is encoded to a path in the caller, so an integer or over-long tenant raises `ArgumentError` without starting a process. - `tenants: [open_timeout: ...]` bounds the wait for an open, default 15000ms. A tenant that outlasts it is stopped. Reaching `max_children` names the option. - Tenant migrations are loaded with `Code.require_file/1`, so a restart or a second repo sharing the path reuses the loaded modules instead of redefining them. - `child_spec/1` reads `tenants:` from the application env, not `config/0`, so the user's `init/2` does not run while the child spec is built. The default `init/2` drops `tenant` and `tenants` before they reach the adapter. --- lib/multi_tenancy.ex | 101 +++++++++++++++++-- lib/multi_tenancy/supervisor.ex | 31 ++++-- lib/multi_tenancy/tenant.ex | 116 ++++++++-------------- lib/repo.ex | 11 +- test/multi_tenancy/multi_tenancy_test.exs | 111 +++++++++++++++++++++ 5 files changed, 279 insertions(+), 91 deletions(-) diff --git a/lib/multi_tenancy.ex b/lib/multi_tenancy.ex index c864018..b4e3246 100644 --- a/lib/multi_tenancy.ex +++ b/lib/multi_tenancy.ex @@ -6,13 +6,21 @@ defmodule AshSqlite.MultiTenancy do @moduledoc """ A `c:AshSqlite.Repo.tenant_repo/2` implementation that gives each tenant its own database file. - Configure the repo with `config :my_app, MyApp.Repo, tenants: [dir: "...", migrations_path: "...", max_children: ...]` + Configure the repo with `config :my_app, MyApp.Repo, tenants: [dir: "...", migrations_path: "...", max_children: ..., open_timeout: ...]` and implement the callback with `def tenant_repo(tenant, context), do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context)`. - A tenant's file is opened and migrated on first use. + A tenant's file is opened and migrated on first use, and `init/2` sees the tenant as `config[:tenant]`. """ alias AshSqlite.MultiTenancy.{Supervisor, Tenant} + @unreserved ~c"abcdefghijklmnopqrstuvwxyz0123456789-_." + + # 255 is the usual per-name limit. `-journal` is the longest suffix SQLite adds to + # a database's name, and `.db` is ours. + @max_encoded_bytes 255 - byte_size(".db") - byte_size("-journal") + + @attempts 100 + @doc "The repo instance serving `tenant`, opening its database if it is not open." @spec tenant_repo(module(), term(), map()) :: pid() def tenant_repo(repo, tenant, _context) do @@ -27,20 +35,95 @@ defmodule AshSqlite.MultiTenancy do """ end + {:ok, dir} = Registry.meta(registry, :dir) + {:ok, open_timeout} = Registry.meta(registry, :open_timeout) + path = Path.join(dir, encode(tenant) <> ".db") + + instance(repo, registry, tenant, path, open_timeout, @attempts) + end + + defp instance(_repo, _registry, tenant, _path, _open_timeout, 0) do + raise "tenant #{inspect(tenant)} kept stopping while being opened" + end + + defp instance(repo, registry, tenant, path, open_timeout, attempts) do pid = case Registry.lookup(registry, tenant) do [{pid, _}] -> pid - [] -> start_tenant(repo, tenant) + [] -> start_tenant(repo, tenant, path) end - Tenant.instance(pid, tenant) + case Tenant.instance(pid, open_timeout) do + {:ok, instance} -> + instance + + :stopped -> + # The Registry drops a dead tenant only once it handles the exit, so a lookup + # straight after can return the same pid again. + Process.sleep(1) + instance(repo, registry, tenant, path, open_timeout, attempts - 1) + + {:timeout, pid} -> + DynamicSupervisor.terminate_child(Supervisor.tenants(repo), pid) + + raise "the database of tenant #{inspect(tenant)} did not open within #{open_timeout}ms. \ + Raise `tenants: [open_timeout: ...]` if opening it legitimately takes longer." + + {:error, reason} -> + raise "could not open the database of tenant #{inspect(tenant)}: #{Exception.format_exit(reason)}" + end + end + + defp start_tenant(repo, tenant, path) do + case DynamicSupervisor.start_child(Supervisor.tenants(repo), {Tenant, {repo, tenant, path}}) do + {:ok, pid} -> + pid + + {:error, {:already_started, pid}} -> + pid + + {:error, :max_children} -> + raise "could not open tenant #{inspect(tenant)}: #{inspect(repo)} already has the \ + most tenants open that `tenants: [max_children: ...]` allows." + + {:error, reason} -> + raise "could not start tenant #{inspect(tenant)}: #{inspect(reason)}" + end end - defp start_tenant(repo, tenant) do - case DynamicSupervisor.start_child(Supervisor.tenants(repo), {Tenant, {repo, tenant}}) do - {:ok, pid} -> pid - {:error, {:already_started, pid}} -> pid - {:error, reason} -> raise "could not start tenant #{inspect(tenant)}: #{inspect(reason)}" + # Escapes rather than sanitises, so no two tenants share a file and no tenant names a + # path outside `dir`. + defp encode(tenant) when is_binary(tenant) and byte_size(tenant) > 0 do + encoded = tenant |> :binary.bin_to_list() |> Enum.map_join(&encode_byte/1) + + if byte_size(encoded) > @max_encoded_bytes do + raise ArgumentError, """ + tenant #{inspect(tenant)} encodes to #{byte_size(encoded)} bytes, and a \ + database name can be at most #{@max_encoded_bytes}. Use a shorter identifier, \ + such as a slug or a UUID, as the tenant. + """ end + + encoded end + + defp encode(tenant) do + raise ArgumentError, """ + expected a non-empty binary tenant, got: #{inspect(tenant)} + + A tenant names a database file, and stringifying another term here would let two \ + tenants that Ash tells apart name one file. Convert it where you set the tenant. + """ + end + + defp encode_byte(byte) when byte in @unreserved, do: <> + + defp encode_byte(byte) do + <> = <> + <> + end + + # Lowercase, so two encodings never differ only by case. + defp hex(nibble) when nibble < 10, do: ?0 + nibble + defp hex(nibble), do: ?a + nibble - 10 end diff --git a/lib/multi_tenancy/supervisor.ex b/lib/multi_tenancy/supervisor.ex index dc2a566..79d49c1 100644 --- a/lib/multi_tenancy/supervisor.ex +++ b/lib/multi_tenancy/supervisor.ex @@ -6,9 +6,7 @@ defmodule AshSqlite.MultiTenancy.Supervisor do @moduledoc false use Supervisor - def child_spec(repo, opts, ecto_child_spec) do - config = Keyword.merge(repo.config(), opts) - + def child_spec(repo, config, ecto_child_spec) do case Keyword.fetch(config, :tenants) do :error -> ecto_child_spec @@ -49,7 +47,11 @@ defmodule AshSqlite.MultiTenancy.Supervisor do {Registry, keys: :unique, name: registry(repo), - meta: [migrations: migrations, dir: Keyword.fetch!(tenants, :dir)]}, + meta: [ + migrations: migrations, + dir: Keyword.fetch!(tenants, :dir), + open_timeout: Keyword.get(tenants, :open_timeout, 15_000) + ]}, {DynamicSupervisor, name: tenants(repo), max_children: Keyword.get(tenants, :max_children, :infinity)} ] ++ base_repo @@ -75,12 +77,25 @@ defmodule AshSqlite.MultiTenancy.Supervisor do |> Enum.sort() end + # A restart, or a second repo sharing the path, reuses the modules already loaded + # rather than redefining code a running migration may be executing. defp migration_module!(file) do - file - |> Code.compile_file() - |> Enum.map(&elem(&1, 0)) - |> Enum.find(&function_exported?(&1, :__migration__, 0)) || + modules = + case Code.require_file(file) do + nil -> modules_compiled_from(file) + compiled -> Enum.map(compiled, &elem(&1, 0)) + end + + Enum.find(modules, &function_exported?(&1, :__migration__, 0)) || raise Ecto.MigrationError, "file #{Path.relative_to_cwd(file)} does not define an Ecto.Migration" end + + defp modules_compiled_from(file) do + source = file |> Path.expand() |> String.to_charlist() + + for {module, _} <- :code.all_loaded(), + module.module_info(:compile)[:source] == source, + do: module + end end diff --git a/lib/multi_tenancy/tenant.ex b/lib/multi_tenancy/tenant.ex index 1d21b68..f956797 100644 --- a/lib/multi_tenancy/tenant.ex +++ b/lib/multi_tenancy/tenant.ex @@ -6,103 +6,75 @@ defmodule AshSqlite.MultiTenancy.Tenant do @moduledoc """ The process that owns one tenant's database: it opens and migrates the file, and its repo instance lives and dies with it. """ - use GenServer, restart: :temporary + use GenServer, restart: :temporary, shutdown: 30_000 - @unreserved ~c"abcdefghijklmnopqrstuvwxyz0123456789-_." - - # 255 is the usual per-name limit. `-shm` is the longest suffix SQLite adds to a - # database's name, and `.db` is ours. - @max_encoded_bytes 255 - byte_size(".db") - byte_size("-shm") - - def start_link({repo, tenant}) do - GenServer.start_link(__MODULE__, {repo, tenant}, + def start_link({repo, tenant, path}) do + GenServer.start_link(__MODULE__, {repo, tenant, path}, name: {:via, Registry, {AshSqlite.MultiTenancy.Supervisor.registry(repo), tenant}} ) end - @doc "The repo instance of the tenant process `pid`, waiting for its database to open." - @spec instance(pid(), term()) :: pid() - def instance(pid, tenant) do - GenServer.call(pid, :instance, :infinity) + @doc "The repo instance of the tenant process `pid`, waiting up to `timeout` for its database to open." + @spec instance(pid(), timeout()) :: + {:ok, pid()} | :stopped | {:timeout, pid()} | {:error, term()} + def instance(pid, timeout) do + {:ok, GenServer.call(pid, :instance, timeout)} catch + :exit, {reason, {GenServer, :call, _}} + when reason in [:noproc, :normal, :shutdown, :killed] -> + :stopped + + :exit, {{:shutdown, _}, {GenServer, :call, _}} -> + :stopped + + :exit, {:timeout, {GenServer, :call, _}} -> + {:timeout, pid} + :exit, reason -> - raise "could not open the database of tenant #{inspect(tenant)}: #{Exception.format_exit(reason)}" + {:error, reason} end @impl true # `DynamicSupervisor.start_child/2` runs `init/1` inside the supervisor, so opening # here would queue every tenant behind every other tenant's open. - def init({repo, tenant}), do: {:ok, {repo, tenant}, {:continue, :open}} + def init({repo, tenant, path}) do + # Trapped so `terminate/2` can stop the instance before this process leaves the + # Registry; otherwise a successor could open the file while the old pool still holds it. + Process.flag(:trap_exit, true) + {:ok, %{repo: repo, tenant: tenant, path: path, instance: nil}, {:continue, :open}} + end @impl true - def handle_continue(:open, {repo, tenant}) do - {:ok, meta} = registry_meta(repo) - dir = Keyword.fetch!(meta, :dir) - File.mkdir_p!(dir) - - {:ok, instance} = - repo.start_link( - name: nil, - database: Path.join(dir, encode(tenant) <> ".db"), - tenant: tenant - ) - - Ecto.Migrator.run(repo, Keyword.fetch!(meta, :migrations), :up, - all: true, - dynamic_repo: instance, - log: false - ) + def handle_continue(:open, state) do + registry = AshSqlite.MultiTenancy.Supervisor.registry(state.repo) + {:ok, migrations} = Registry.meta(registry, :migrations) + File.mkdir_p!(Path.dirname(state.path)) + + {:ok, instance} = state.repo.start_link(name: nil, database: state.path, tenant: state.tenant) + state = %{state | instance: instance} + Ecto.Migrator.run(state.repo, migrations, :up, all: true, dynamic_repo: instance, log: false) Ecto.Adapters.SQL.query!(instance, "SELECT 1", []) - {:noreply, %{repo: repo, tenant: tenant, instance: instance}} + {:noreply, state} end @impl true def handle_call(:instance, _from, state), do: {:reply, state.instance, state} - defp registry_meta(repo) do - registry = AshSqlite.MultiTenancy.Supervisor.registry(repo) - - with {:ok, migrations} <- Registry.meta(registry, :migrations), - {:ok, dir} <- Registry.meta(registry, :dir) do - {:ok, migrations: migrations, dir: dir} - end - end - - # Escapes rather than sanitises, so no two tenants share a file and no tenant names a - # path outside `dir`. - defp encode(tenant) when is_binary(tenant) and byte_size(tenant) > 0 do - encoded = tenant |> :binary.bin_to_list() |> Enum.map_join(&encode_byte/1) - - if byte_size(encoded) > @max_encoded_bytes do - raise ArgumentError, """ - tenant #{inspect(tenant)} encodes to #{byte_size(encoded)} bytes, and a \ - database name can be at most #{@max_encoded_bytes}. Use a shorter identifier, \ - such as a slug or a UUID, as the tenant. - """ - end - - encoded - end - - defp encode(tenant) do - raise ArgumentError, """ - expected a non-empty binary tenant, got: #{inspect(tenant)} - - A tenant names a database file, and stringifying another term here would let two \ - tenants that Ash tells apart name one file. Convert it where you set the tenant. - """ + @impl true + def handle_info({:EXIT, instance, reason}, %{instance: instance} = state) do + {:stop, reason, %{state | instance: nil}} end - defp encode_byte(byte) when byte in @unreserved, do: <> + def handle_info({:EXIT, _pid, _reason}, state), do: {:noreply, state} - defp encode_byte(byte) do - <> = <> - <> + @impl true + def terminate(_reason, %{instance: instance}) when is_pid(instance) do + Supervisor.stop(instance, :shutdown) + catch + :exit, _ -> :ok end - # Lowercase, so two encodings never differ only by case. - defp hex(nibble) when nibble < 10, do: ?0 + nibble - defp hex(nibble), do: ?a + nibble - 10 + def terminate(_reason, _state), do: :ok end diff --git a/lib/repo.ex b/lib/repo.ex index 9e2fa53..758a2d4 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -68,12 +68,18 @@ defmodule AshSqlite.Repo do defoverridable child_spec: 1 @behaviour AshSqlite.Repo + @ash_sqlite_otp_app otp_app @before_compile AshSqlite.Repo.BeforeCompile defoverridable insert: 2, insert: 1, insert!: 2, insert!: 1 - def child_spec(opts), - do: AshSqlite.MultiTenancy.Supervisor.child_spec(__MODULE__, opts, super(opts)) + def child_spec(opts) do + AshSqlite.MultiTenancy.Supervisor.child_spec( + __MODULE__, + Keyword.merge(Application.get_env(@ash_sqlite_otp_app, __MODULE__, []), opts), + super(Keyword.delete(opts, :tenants)) + ) + end def installed_extensions, do: [] def migrations_path, do: nil @@ -83,6 +89,7 @@ defmodule AshSqlite.Repo do def init(_, config) do new_config = config + |> Keyword.drop([:tenant, :tenants]) |> Keyword.put(:installed_extensions, installed_extensions()) |> Keyword.put(:migrations_path, migrations_path()) |> Keyword.put(:case_sensitive_like, :on) diff --git a/test/multi_tenancy/multi_tenancy_test.exs b/test/multi_tenancy/multi_tenancy_test.exs index 2cabf77..12feb57 100644 --- a/test/multi_tenancy/multi_tenancy_test.exs +++ b/test/multi_tenancy/multi_tenancy_test.exs @@ -150,4 +150,115 @@ defmodule AshSqlite.MultiTenancyTest do for ref <- refs, do: assert_receive({:DOWN, ^ref, _, _, _}) end + + test "tenants that differ only by case get different files", %{dir: dir} do + start_repo(dir) + + create!("Acme", "upper") + create!("acme", "lower") + + assert length(db_files(dir)) == 2 + assert titles("Acme") == ["upper"] + end + + test "a bad tenant is refused in the caller, without starting a process", %{dir: dir} do + start_repo(dir) + + assert_raise ArgumentError, ~r/expected a non-empty binary tenant/, fn -> + MultiTenancyRepo.tenant_repo(1, %{}) + end + + assert_raise ArgumentError, ~r/encodes to/, fn -> + MultiTenancyRepo.tenant_repo(String.duplicate("A", 100), %{}) + end + + assert %{active: 0} = DynamicSupervisor.count_children(MultiTenancyRepo.Tenants) + end + + test "a caller that finds a dead tenant still in the Registry opens a new one", %{dir: dir} do + start_repo(dir) + old_instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + Process.exit(tenant, :kill) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + + assert instance != old_instance + assert Process.alive?(instance) + end + + test "stopping a tenant stops its instance before it returns", %{dir: dir} do + start_repo(dir) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + :ok = DynamicSupervisor.terminate_child(MultiTenancyRepo.Tenants, tenant) + + refute Process.alive?(instance) + end + + test "a tenant whose instance crashes stops too", %{dir: dir} do + start_repo(dir) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + ref = Process.monitor(tenant) + + Process.exit(instance, :kill) + + assert_receive {:DOWN, ^ref, _, _, _} + end + + test "an open that outlasts open_timeout raises and stops the tenant", %{dir: dir} do + migrations = Path.join(dir, "migrations") + File.mkdir_p!(migrations) + + File.write!(Path.join(migrations, "20260101000000_slow.exs"), """ + defmodule AshSqlite.Test.MultiTenancyMigrations.Slow do + use Ecto.Migration + def up, do: Process.sleep(1_000) + end + """) + + start_repo(Path.join(dir, "tenants"), migrations_path: migrations, open_timeout: 50) + + assert_raise RuntimeError, ~r/did not open within 50ms/, fn -> + MultiTenancyRepo.tenant_repo("acme", %{}) + end + + assert %{active: 0} = DynamicSupervisor.count_children(MultiTenancyRepo.Tenants) + end + + test "reaching max_children names the option", %{dir: dir} do + start_repo(dir, max_children: 1) + MultiTenancyRepo.tenant_repo("acme", %{}) + + assert_raise RuntimeError, ~r/max_children/, fn -> + MultiTenancyRepo.tenant_repo("globex", %{}) + end + end + + test "restarting the repo does not recompile the migrations", %{dir: dir} do + start_repo(dir) + stop_supervised!(MultiTenancyRepo) + + stderr = ExUnit.CaptureIO.capture_io(:stderr, fn -> start_repo(dir) end) + + refute stderr =~ "redefining module" + MultiTenancyRepo.tenant_repo("acme", %{}) + end + + test "tenants: is read from the application env, not from init/2", %{dir: dir} do + Application.put_env(:ash_sqlite, MultiTenancyRepo, tenants: [dir: dir]) + on_exit(fn -> Application.delete_env(:ash_sqlite, MultiTenancyRepo) end) + + assert %{start: {AshSqlite.MultiTenancy.Supervisor, :start_link, _}} = + MultiTenancyRepo.child_spec([]) + end + + test "init/2 keeps tenant and tenants away from the adapter" do + {:ok, config} = MultiTenancyRepo.init(:runtime, tenant: "acme", tenants: [dir: "x"]) + + refute Keyword.has_key?(config, :tenant) + refute Keyword.has_key?(config, :tenants) + end end From 2598bd14c6184689071a85886f5e7d44f41c56d1 Mon Sep 17 00:00:00 2001 From: Conor Sinclair Date: Sun, 18 Oct 2026 21:58:20 +0200 Subject: [PATCH 3/4] docs: show the tenants config and tenant_repo/2 callback for AshSqlite.MultiTenancy --- lib/multi_tenancy.ex | 22 +++++++++++++++++++--- lib/repo.ex | 7 +++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/multi_tenancy.ex b/lib/multi_tenancy.ex index b4e3246..89642b1 100644 --- a/lib/multi_tenancy.ex +++ b/lib/multi_tenancy.ex @@ -6,9 +6,25 @@ defmodule AshSqlite.MultiTenancy do @moduledoc """ A `c:AshSqlite.Repo.tenant_repo/2` implementation that gives each tenant its own database file. - Configure the repo with `config :my_app, MyApp.Repo, tenants: [dir: "...", migrations_path: "...", max_children: ..., open_timeout: ...]` - and implement the callback with `def tenant_repo(tenant, context), do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context)`. - A tenant's file is opened and migrated on first use, and `init/2` sees the tenant as `config[:tenant]`. + A tenant's file is opened and migrated on first use, and the repo's `init/2` sees the tenant as `config[:tenant]`. + Only `dir` is required. `max_children` defaults to `:infinity` and `open_timeout`, in milliseconds, to 15000. + + ```elixir + config :my_app, MyApp.Repo, + tenants: [ + dir: "priv/tenants", + migrations_path: "priv/repo/tenant_migrations", + max_children: 1_000, + open_timeout: 15_000 + ] + + defmodule MyApp.Repo do + use AshSqlite.Repo, otp_app: :my_app + + def tenant_repo(tenant, context), + do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context) + end + ``` """ alias AshSqlite.MultiTenancy.{Supervisor, Tenant} diff --git a/lib/repo.ex b/lib/repo.ex index 758a2d4..69c85e9 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -20,6 +20,10 @@ defmodule AshSqlite.Repo do - `:tenant_migrations_path` - The path where your tenant migrations are stored (only relevant for a multitenant implementation) - `:snapshots_path` - The path where the resource snapshots for the migration generator are stored. + + In the repo's application config, as it is read before `init/2` runs: + + - `:tenants` - Opens one database file per tenant. See `AshSqlite.MultiTenancy` for its options. """ @doc "Use this to inform the data layer about what extensions are installed" @@ -41,6 +45,9 @@ defmodule AshSqlite.Repo do instance. Every resource on a tenant repo must use `strategy :context`, so put tables shared across tenants on a repo module of their own. + `AshSqlite.MultiTenancy.tenant_repo/3` is a default implementation that opens + one database file per tenant. + `context.type` is `:read` for queries and aggregates, and `:mutate` for writes and for opening a transaction. """ From 82c20bb3301e9daedd4b6f588f3719394263eced Mon Sep 17 00:00:00 2001 From: Conor Sinclair Date: Mon, 19 Oct 2026 08:55:31 +0200 Subject: [PATCH 4/4] improvement: start a tenant directly instead of looking it up and polling `AshSqlite.MultiTenancy.tenant_repo/3` calls `DynamicSupervisor.start_child/2` every time. `{:already_started, pid}` means the tenant is running. A call to a tenant that is stopping returns only after it has exited, and a `:via` registration replaces a dead pid's Registry entry, so the retry starts a new tenant without sleeping. --- lib/multi_tenancy.ex | 22 +++++++++------------- lib/multi_tenancy/tenant.ex | 6 +++++- test/multi_tenancy/multi_tenancy_test.exs | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/lib/multi_tenancy.ex b/lib/multi_tenancy.ex index 89642b1..7e9d206 100644 --- a/lib/multi_tenancy.ex +++ b/lib/multi_tenancy.ex @@ -35,7 +35,7 @@ defmodule AshSqlite.MultiTenancy do # a database's name, and `.db` is ours. @max_encoded_bytes 255 - byte_size(".db") - byte_size("-journal") - @attempts 100 + @attempts 3 @doc "The repo instance serving `tenant`, opening its database if it is not open." @spec tenant_repo(module(), term(), map()) :: pid() @@ -55,29 +55,24 @@ defmodule AshSqlite.MultiTenancy do {:ok, open_timeout} = Registry.meta(registry, :open_timeout) path = Path.join(dir, encode(tenant) <> ".db") - instance(repo, registry, tenant, path, open_timeout, @attempts) + instance(repo, tenant, path, open_timeout, @attempts) end - defp instance(_repo, _registry, tenant, _path, _open_timeout, 0) do + defp instance(_repo, tenant, _path, _open_timeout, 0) do raise "tenant #{inspect(tenant)} kept stopping while being opened" end - defp instance(repo, registry, tenant, path, open_timeout, attempts) do - pid = - case Registry.lookup(registry, tenant) do - [{pid, _}] -> pid - [] -> start_tenant(repo, tenant, path) - end + defp instance(repo, tenant, path, open_timeout, attempts) do + pid = start_tenant(repo, tenant, path) case Tenant.instance(pid, open_timeout) do {:ok, instance} -> instance + # `:stopped` arrives only after that tenant has exited, and registering a `:via` + # name replaces a dead pid's entry, so the next start registers a new tenant. :stopped -> - # The Registry drops a dead tenant only once it handles the exit, so a lookup - # straight after can return the same pid again. - Process.sleep(1) - instance(repo, registry, tenant, path, open_timeout, attempts - 1) + instance(repo, tenant, path, open_timeout, attempts - 1) {:timeout, pid} -> DynamicSupervisor.terminate_child(Supervisor.tenants(repo), pid) @@ -95,6 +90,7 @@ defmodule AshSqlite.MultiTenancy do {:ok, pid} -> pid + # May be a tenant that is stopping; `instance/5` waits for it and retries. {:error, {:already_started, pid}} -> pid diff --git a/lib/multi_tenancy/tenant.ex b/lib/multi_tenancy/tenant.ex index f956797..b1e9671 100644 --- a/lib/multi_tenancy/tenant.ex +++ b/lib/multi_tenancy/tenant.ex @@ -14,7 +14,11 @@ defmodule AshSqlite.MultiTenancy.Tenant do ) end - @doc "The repo instance of the tenant process `pid`, waiting up to `timeout` for its database to open." + @doc """ + The repo instance of the tenant process `pid`, waiting up to `timeout` for its database to open. + + `:stopped` means the process has exited. + """ @spec instance(pid(), timeout()) :: {:ok, pid()} | :stopped | {:timeout, pid()} | {:error, term()} def instance(pid, timeout) do diff --git a/test/multi_tenancy/multi_tenancy_test.exs b/test/multi_tenancy/multi_tenancy_test.exs index 12feb57..93e7241 100644 --- a/test/multi_tenancy/multi_tenancy_test.exs +++ b/test/multi_tenancy/multi_tenancy_test.exs @@ -261,4 +261,20 @@ defmodule AshSqlite.MultiTenancyTest do refute Keyword.has_key?(config, :tenant) refute Keyword.has_key?(config, :tenants) end + + test "a caller arriving while a tenant stops waits for it, then opens a new one", %{dir: dir} do + start_repo(dir) + old_instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + stopping = + Task.async(fn -> DynamicSupervisor.terminate_child(MultiTenancyRepo.Tenants, tenant) end) + + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + Task.await(stopping) + + assert instance != old_instance + assert Process.alive?(instance) + refute Process.alive?(old_instance) + end end