Repository navigation
improvement: open one database per tenant under a supervisor the repo starts - #246
Conversation
… 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.
| def child_spec(repo, config, ecto_child_spec) do | ||
| case Keyword.fetch(config, :tenants) do | ||
| :error -> | ||
| ecto_child_spec |
There was a problem hiding this comment.
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
| def init(_, config) do | ||
| new_config = | ||
| config | ||
| |> Keyword.drop([:tenant, :tenants]) |
There was a problem hiding this comment.
These aren't Ecto opts, so we drop them for good measure
| def tenant_repo(tenant, context), | ||
| do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context) |
There was a problem hiding this comment.
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.
| ```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 |
There was a problem hiding this comment.
This ends up being all that is required to have tenant specific SQLite databases:
A tenants: key in config + the tenant_repo callback 🚀
| :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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
|
🚀 Thank you for your contribution! 🚀 |
This PR adds
AshSqlite.MultiTenancy, a defaultc:AshSqlite.Repo.tenant_repo/2implementation 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.