Skip to content

improvement: open one database per tenant under a supervisor the repo starts - #246

Merged
zachdaniel merged 4 commits into
ash-project:mainfrom
C-Sinclair:tenant-supervisor
Oct 19, 2026
Merged

zachdaniel merged 4 commits into
ash-project:mainfrom
C-Sinclair:tenant-supervisor

Conversation

@C-Sinclair

@C-Sinclair C-Sinclair commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

This PR adds AshSqlite.MultiTenancy, a default c:AshSqlite.Repo.tenant_repo/2 implementation that opens one SQLite file per tenant.

It builds on the tenant repo option added in #224, which #245 moved onto a repo callback. @zachdaniel judged my first attempt at this implementation in #225 overcomplicated, quite rightly so!

This rewrite keeps only one process per tenant under a supervisor the repo starts, as suggested.

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

… 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.
- `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.
Comment on lines +9 to +12
def child_spec(repo, config, ecto_child_spec) do
case Keyword.fetch(config, :tenants) do
:error ->
ecto_child_spec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For tenant_repo's we override the child_spec/1 so we can control the supervision tree beneath the Repo.

But for Repo's which don't provide the tenants: config, we just return Ecto's standard child_spec

Comment thread lib/repo.ex
def init(_, config) do
new_config =
config
|> Keyword.drop([:tenant, :tenants])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These aren't Ecto opts, so we drop them for good measure

Comment on lines +15 to +16
def tenant_repo(tenant, context),
do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at having the use AshSqlite.Repo expose a tenant key so this function could be autogenerated, but I actually think explicit is better here.

Comment thread lib/multi_tenancy.ex
Comment on lines +12 to +26
```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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ends up being all that is required to have tenant specific SQLite databases:
A tenants: key in config + the tenant_repo callback 🚀

@C-Sinclair
C-Sinclair marked this pull request as ready for review October 18, 2026 19:59
Comment thread lib/multi_tenancy.ex Outdated
: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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just call start_tenant directly instead of lookup + sleep:

  • {:already_started, pid} means its running and we're all good
  • a shutting down tenant should block

Then we don't have to do this busy wait

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good shout! tenant_repo/3 now calls start_child every time instead of looking the tenant up first.
If the tenant is stopping, the caller blocks until it exits and then starts a new one. Registering the :via name replaces a dead pid's entry, so the retry no longer needs the sleep.

…ling

`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.
@zachdaniel
zachdaniel merged commit 701530c into ash-project:main Oct 19, 2026
51 checks passed
@zachdaniel

Copy link
Copy Markdown
Contributor

🚀 Thank you for your contribution! 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants